using System.Net; using System.Net.Http.Json; using Microsoft.EntityFrameworkCore; using WordVersioning.Api.Services; namespace WordVersioning.Api.IntegrationTests; /// FR-16 — ไฟล์ร่าง (draft): generate docx จาก template โดยไม่เกิด Document/version ใน DB /// (v9: เก็บไฟล์ชั่วคราวใต้ drafts/ + คืน presigned URL ให้เว็บเปิดใน Word + ค่าที่แทนที่ได้ formatting ไทยถูกต้อง) [Collection(IntegrationTestCollection.Name)] public sealed class DraftTests(IntegrationTestFixture fixture) { [Fact] public async Task Draft_FillsAllFields_PreservesFormatting_StampsOnlyTemplateId_CreatesNothing() { using var client = fixture.CreateClient(); var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "draft หลัก"); using var response = await client.PostAsJsonAsync( $"api/templates/{templateId}/generate-draft", new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json); Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ไม่ใช่ 201 — ไม่มีอะไรถูกสร้างใน DB // คืน presigned URL อายุสั้น (เปิดใน Word ผ่าน ms-word:ofe / ดาวน์โหลดสำรอง) — ดึงไฟล์จริงได้ทันที var link = (await response.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; Assert.False(string.IsNullOrWhiteSpace(link.Url)); Assert.True(link.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(30)); using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน using var file = await anon.GetAsync(new Uri(link.Url).PathAndQuery); Assert.Equal(HttpStatusCode.OK, file.StatusCode); var fileBytes = await file.Content.ReadAsByteArrayAsync(); // (1) ค่าทุก field ถูกแทนที่ใน SDT — เหมือน generate ปกติ 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 ยังรักษา 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); // (3) stamp เฉพาะ TemplateId — ไม่มี SavedByAddIn/DocumentTrackingId เพื่อให้ add-in เห็นเป็น untracked (FR-16) Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName)); Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn")); Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId")); // (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() .AnyAsync(v => db.Documents.Any(d => d.Id == v.DocumentId && d.TemplateId == templateId))); Assert.False(await db.DocumentFieldValues.AsNoTracking() .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 { ["detail1"] = detail, ["remark1"] = "หมายเหตุผู้พิพากษา" } }, IntegrationTestFixture.Json); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var link = (await response.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน using var file = await anon.GetAsync(new Uri(link.Url).PathAndQuery); 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); } /// สร้าง template จาก BuildThaiTemplateDocx พร้อมตั้ง field defs (detail1 required, remark1 ทางเลือก) private async Task 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(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() { using var client = fixture.CreateClient(); var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "draft required ขาด"); var values = new Dictionary(TestFlow.DefaultValues); values.Remove("employee_name"); // required field หาย using var response = await client.PostAsJsonAsync( $"api/templates/{templateId}/generate-draft", new { 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 Draft_WrongFieldType_Returns400(string key, string badValue) { using var client = fixture.CreateClient(); var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, $"draft type ผิด {key}"); var values = new Dictionary(TestFlow.DefaultValues) { [key] = badValue }; using var response = await client.PostAsJsonAsync( $"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task Draft_UnknownTemplate_Returns404() { using var client = fixture.CreateClient(); using var response = await client.PostAsJsonAsync( $"api/templates/{Guid.NewGuid()}/generate-draft", new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task Draft_DeactivatedTemplate_Returns400() { using var client = fixture.CreateClient(); var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "draft deactivate"); using var deactivate = await client.DeleteAsync($"api/templates/{templateId}"); Assert.Equal(HttpStatusCode.NoContent, deactivate.StatusCode); using var response = await client.PostAsJsonAsync( $"api/templates/{templateId}/generate-draft", new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task Draft_IgnoresUndefinedValues() { using var client = fixture.CreateClient(); var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "draft ค่าเกินนิยาม"); var values = new Dictionary(TestFlow.DefaultValues) { ["field_ที่ไม่มีใน_template"] = "ส่งเกินมา", // policy FR-11: ignore }; using var response = await client.PostAsJsonAsync( $"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var link = (await response.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน using var file = await anon.GetAsync(new Uri(link.Url).PathAndQuery); 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_ValueWithInvalidXmlControlChars_IsStrippedNot500() { // ผู้ใช้ paste ข้อความที่มีอักขระควบคุม (vertical tab 0x0B / form feed 0x0C) มาในค่า field — // XML 1.0 ห้ามอักขระพวกนี้ เดิม XmlWriter โยน ArgumentException ตอน save ทั้ง package กลายเป็น 500 // ต้องตัดออกเงียบๆ แล้วสร้างไฟล์สำเร็จ (อักขระมองไม่เห็นอยู่แล้ว) — ครอบทั้ง draft และ generate v0 using var client = fixture.CreateClient(); var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "paste control chars"); var values = new Dictionary(TestFlow.DefaultValues) { ["position"] = "วิศวกร\u000Bซอฟต์\u000Cแวร์", // VT/FF ฝังเป็น escape กันอักขระล่องหนหายระหว่างแก้ไขไฟล์ }; // ไฟล์ร่าง using var draft = await client.PostAsJsonAsync( $"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json); Assert.Equal(HttpStatusCode.OK, draft.StatusCode); var link = (await draft.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน var draftBytes = await (await anon.GetAsync(new Uri(link.Url).PathAndQuery)).Content.ReadAsByteArrayAsync(); Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(draftBytes, "position")); // generate v0 ใช้ ReplaceSdtContent ตัวเดียวกัน — ตรวจซ้ำจุดเดียวกันผ่านอีก endpoint var doc = await TestFlow.GenerateDocumentAsync(client, templateId, values); using var download = await client.GetAsync(doc.DownloadUrl); var docBytes = await download.Content.ReadAsByteArrayAsync(); Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(docBytes, "position")); } [Fact] public async Task Draft_OpenLink_BadToken_Returns404_AndNoBearerNeeded() { // /api/open ใช้ลายเซ็นใน path แทน Bearer ([AllowAnonymous] โดยเจตนา — Word/เบราว์เซอร์แนบ header ไม่ได้) // รูป URL: {BaseUrl}/api/open/{token}/{expiry}/d/{templateId}/{stamp}/{fileName} — ไม่มี query string // เพราะ Word 16.101+ บน Mac เมิน URL ที่มี query แบบเงียบๆ (v9.2) · token ผิด/หมดอายุ → 404 using var client = fixture.CreateClient(); var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "openlink bad token"); using var draft = await client.PostAsJsonAsync( $"api/templates/{templateId}/generate-draft", new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json); var link = (await draft.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; Assert.Contains("/api/open/", link.Url); Assert.DoesNotContain("?", link.Url); // ทุกอย่างอยู่ใน path — ไม่มี query string Assert.StartsWith("https://test-host/api/open/", link.Url); // ออกผ่าน BaseUrl (https) ของ backend ไม่ใช่ presigned MinIO // ชื่อไฟล์ท้าย URL เป็นชื่อจริงตามหลักเดิม ({template}_{timestamp}.docx) ไม่ใช่คำว่า file — // Word ตั้งชื่อ copy ตาม URL ตอนเปิด และ browser ใช้ URL เป็น fallback ตอนดาวน์โหลด var tail = Uri.UnescapeDataString(new Uri(link.Url).AbsolutePath.Split('/')[^1]); Assert.StartsWith("openlink bad token", tail); Assert.EndsWith(".docx", tail); Assert.DoesNotContain("/file.docx", new Uri(link.Url).AbsolutePath); // แทน token ใน path ด้วยขยะ (segment ที่ 3 หลัง /api/open/) var segments = new Uri(link.Url).AbsolutePath.Split('/'); segments[3] = "garbage-token"; using var anon = fixture.CreateAnonymousClient(); using var bad = await anon.GetAsync("/" + string.Join("/", segments.Skip(1))); Assert.Equal(HttpStatusCode.NotFound, bad.StatusCode); var body = await bad.Content.ReadAsStringAsync(); Assert.Contains("หมดอายุ", body); } [Theory] [InlineData("stamp")] // แก้ stamp ของ object key (valid token) — resource เปลี่ยน ลายเซ็นต้องพัง [InlineData("templateId")] // แก้ templateId ใน path — เช่นกัน public async Task Draft_OpenLink_TamperedRouteSegments_Return404(string which) { using var client = fixture.CreateClient(); var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, $"openlink tamper {which}"); using var draft = await client.PostAsJsonAsync( $"api/templates/{templateId}/generate-draft", new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json); var link = (await draft.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; // path: /api/open/{t}/{e}/d/{templateId}/{stamp}/{fileName} var segments = new Uri(link.Url).AbsolutePath.Split('/'); switch (which) { case "stamp": segments[6] = "20990101000000_" + Guid.NewGuid().ToString("N"); break; case "templateId": segments[5] = Guid.NewGuid().ToString(); break; } using var anon = fixture.CreateAnonymousClient(); using var response = await anon.GetAsync("/" + string.Join("/", segments.Skip(1))); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task Version_OpenLink_SwappedExtension_Returns404() { // นามสกุลท้าย URL ของ version เลือก format ซึ่งผูกกับลายเซ็น — เปลี่ยน .docx→.pdf ด้วย token เดิมต้อง 404 // (draft ไม่มีเคสนี้: format เป็น docx เสมอและไม่มี pdf ให้สลับ) using var client = fixture.CreateClient(); var documentId = Guid.NewGuid(); using var upload = await client.PostAsync($"api/documents/{documentId}/versions", ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "คำร้อง.docx", Guid.NewGuid().ToString())); var uploaded = (await upload.Content.ReadFromJsonAsync(IntegrationTestFixture.Json))!; var link = await client.GetFromJsonAsync( $"api/documents/{documentId}/versions/{uploaded.VersionId}/download-link"); var segments = new Uri(link!.Url).AbsolutePath.Split('/'); // /api/open/{t}/{e}/v/{did}/{vid}/{fileName} segments[^1] = Uri.EscapeDataString("คำร้อง_v1.pdf"); // format signed เป็น docx using var anon = fixture.CreateAnonymousClient(); using var response = await anon.GetAsync("/" + string.Join("/", segments.Skip(1))); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [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(() => storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 0, "test-expire-probe")); } }