eCourt-Doc-Edit/backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs
Suphonchai Phoonsawat 39d610a781 Phase 11: version PDF (FR-15)
- เลข version ใหม่: เว็บ-generate = v0 ฉบับตั้งต้น (ไม่มี PDF), add-in save (Start Versioning + Save & Version) = v1+ มี PDF เสมอ
- PDF ผลิตจาก Word จริงผ่าน task pane เท่านั้น (getFileAsync Pdf + runtime guard, manifest ไม่เพิ่ม requirement); capture/upload เป็น best-effort — docx ผ่านแม้ PDF ล้ม + MessageBar เตือนตาม hasPdf
- Backend: DocumentVersion.PdfObjectKey (nullable, migration AddVersionPdf) + sibling .pdf ใน bucket docx-versions · multipart part pdf (ผิด type → 400) · /download + /download-link รับ ?format=pdf (ไม่มี → 404) · presigned ฝังชื่อไฟล์ไทย + _vN (RFC 5987)
- Web: เปิดไฟล์แทน blob-download — PDF แท็บใหม่, docx ผ่าน ms-word:ofe|u| พร้อม heuristic fallback ลิงก์สำรอง (useVersionOpen, versionDownload) · ปุ่ม "เปิดใน Word" ที่หน้า success ของฟอร์มสร้างเอกสาร
- Spec v7 + CONTEXT.md + ADR 0001 + แผน docs/plans/version-pdf.md
- Automated QA: backend 68 / add-in 63 / web 55 ผ่าน · manual acceptance ผ่านบน Word for Mac (2026-09-08) รวม fidelity + track changes — ค้างเฉพาะ Windows / Word on the web

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 14:01:13 +07:00

219 lines
12 KiB
C#
Raw 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: ค่าใหม่เขียนที่ Text แรก (ใน Run แรก — สืบทอด rPr bold ของ template)
// และ 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);
}
}