แก้ตามผลใช้งานจริง 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>
219 lines
12 KiB
C#
219 lines
12 KiB
C#
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);
|
||
}
|
||
}
|