- เลข 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>
147 lines
9.5 KiB
C#
147 lines
9.5 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace WordVersioning.Api.IntegrationTests;
|
|
|
|
/// <summary>FR-11 — field sync ตอน Save & Version: latest values อัปเดต + snapshot ต่อ version
|
|
/// + missing-field policy (AC-13: ค่าเปลี่ยนตาม, AC-14: field หาย → ค่าเดิม + mark missing ไม่ fail)</summary>
|
|
[Collection(IntegrationTestCollection.Name)]
|
|
public sealed class FieldSyncTests(IntegrationTestFixture fixture)
|
|
{
|
|
/// <summary>สร้างเอกสารจาก template ที่ตั้งค่าแล้ว (v0 ค่า DefaultValues) — จุดตั้งต้นของทุก test ในคลาสนี้</summary>
|
|
private async Task<(HttpClient Client, Guid DocumentId, Guid V0VersionId)> CreateTrackedDocumentAsync()
|
|
{
|
|
var client = fixture.CreateClient();
|
|
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "field sync");
|
|
var doc = await TestFlow.GenerateDocumentAsync(client, templateId);
|
|
return (client, doc.DocumentId, doc.VersionId);
|
|
}
|
|
|
|
/// <summary>AC-13 — แก้ค่าใน content control แล้ว Save & Version → latest values เปลี่ยนตาม
|
|
/// + snapshot ของ version นั้นเก็บค่าใหม่ และ v0 snapshot ยังเก็บค่าเก่าไว้ (diff ได้)</summary>
|
|
[Fact]
|
|
public async Task UploadWithChangedFields_UpdatesLatestValues_AndSnapshotsPerVersion()
|
|
{
|
|
var (client, documentId, v0VersionId) = await CreateTrackedDocumentAsync();
|
|
|
|
var v1Fields = new Dictionary<string, string>
|
|
{
|
|
["employee_name"] = "สมหญิง เปลี่ยนชื่อแล้ว",
|
|
["position"] = "วิศวกรซอฟต์แวร์", // ไม่เปลี่ยน
|
|
["salary"] = "99000",
|
|
["start_date"] = "2026-08-01", // ไม่เปลี่ยน
|
|
};
|
|
using var upload = await client.PostAsync(
|
|
$"api/documents/{documentId}/versions",
|
|
ApiExtensions.VersionForm(
|
|
TestDocx.BuildPlainDocx("แก้ไขใน Word"), "doc.docx", Guid.NewGuid().ToString(),
|
|
JsonSerializer.Serialize(v1Fields)));
|
|
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
|
|
var v1 = await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
|
|
|
|
// latest values: field ที่ "ส่งมา" ถูก update ทุกตัว (แม้ค่าเดิมไม่เปลี่ยน — ไม่มี dirty check)
|
|
// จึงชี้ SourceVersionId = v1 ทั้งหมด; ตัวที่ "ไม่ได้ส่ง" เท่านั้นที่คง v0 (ดู test AC-14)
|
|
await using var db = fixture.CreateDb();
|
|
var values = await db.DocumentFieldValues.AsNoTracking()
|
|
.Where(v => v.DocumentId == documentId)
|
|
.ToDictionaryAsync(v => v.FieldKey);
|
|
Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", values["employee_name"].Value);
|
|
Assert.Equal(v1!.VersionId, values["employee_name"].SourceVersionId);
|
|
Assert.Equal("99000", values["salary"].Value);
|
|
Assert.Equal(v1.VersionId, values["salary"].SourceVersionId);
|
|
Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value); // ค่าเดิมส่งมาเท่าเดิม → คงเดิมแต่ source ขยับตาม v1
|
|
Assert.Equal(v1.VersionId, values["position"].SourceVersionId);
|
|
Assert.Equal(fixture.TestUserId, values["employee_name"].UpdatedByUserId);
|
|
|
|
// snapshot ต่อ version ผ่าน history API: v1 = ค่าใหม่, v0 = ค่าเดิม (missing=false ทั้งคู่)
|
|
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
|
|
var items = (await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json))!
|
|
.OrderBy(i => i.VersionNumber)
|
|
.ToList();
|
|
|
|
var v0Snapshot = Snapshot(items[0].Fields!.Value);
|
|
var v1Snapshot = Snapshot(items[1].Fields!.Value);
|
|
Assert.Equal("สมหญิง รักงาน", v0Snapshot["employee_name"].Value);
|
|
Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", v1Snapshot["employee_name"].Value);
|
|
Assert.Equal("99000", v1Snapshot["salary"].Value);
|
|
Assert.All(v0Snapshot.Values, e => Assert.False(e.Missing));
|
|
Assert.All(v1Snapshot.Values, e => Assert.False(e.Missing));
|
|
}
|
|
|
|
/// <summary>AC-14 — ลบ content control แล้ว Save & Version: request สำเร็จ, ค่าเดิมคงอยู่ + mark missing
|
|
/// ใน snapshot; field ที่ส่งเกินนิยามถูก ignore</summary>
|
|
[Fact]
|
|
public async Task UploadWithMissingFields_KeepsOldValues_MarksMissing_DoesNotFail()
|
|
{
|
|
var (client, documentId, v0VersionId) = await CreateTrackedDocumentAsync();
|
|
|
|
// ผู้ใช้ลบ control ของ position/salary/start_date — เหลือส่ง employee_name เท่านั้น + ส่งเกิน 1 field
|
|
using var upload = await client.PostAsync(
|
|
$"api/documents/{documentId}/versions",
|
|
ApiExtensions.VersionForm(
|
|
TestDocx.BuildPlainDocx("ลบ control ไป"), "doc.docx", Guid.NewGuid().ToString(),
|
|
"""{"employee_name":"สมหญิง ยังอยู่","อะไรที่ไม่มีในนิยาม":"ส่งเกินมา"}"""));
|
|
|
|
// policy FR-11: ไม่ fail request
|
|
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
|
|
|
|
// latest values ของ field ที่หาย: คงค่า v0 เดิมไว้ทั้งหมด (รวม SourceVersionId ที่ยังชี้ v0)
|
|
await using var db = fixture.CreateDb();
|
|
var values = await db.DocumentFieldValues.AsNoTracking()
|
|
.Where(v => v.DocumentId == documentId)
|
|
.ToDictionaryAsync(v => v.FieldKey);
|
|
Assert.Equal("สมหญิง ยังอยู่", values["employee_name"].Value);
|
|
Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value);
|
|
Assert.Equal(v0VersionId, values["position"].SourceVersionId); // ไม่ถูกแตะเพราะไม่ได้ส่งมา
|
|
Assert.Equal("75000", values["salary"].Value);
|
|
Assert.Equal("2026-08-01", values["start_date"].Value);
|
|
Assert.Equal(4, values.Count); // field เกินไม่ถูกเก็บ
|
|
|
|
// snapshot v1: field ที่หาย missing=true พร้อมค่าเดิม, ไม่มี key ของ field เกิน
|
|
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
|
|
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
|
|
var snapshot = Snapshot(items!.Single(i => i.VersionNumber == 1).Fields!.Value);
|
|
|
|
Assert.False(snapshot["employee_name"].Missing);
|
|
Assert.True(snapshot["position"].Missing);
|
|
Assert.Equal("วิศวกรซอฟต์แวร์", snapshot["position"].Value); // ค่าเดิม
|
|
Assert.True(snapshot["salary"].Missing);
|
|
Assert.True(snapshot["start_date"].Missing);
|
|
Assert.Equal(4, snapshot.Count); // ไม่มี "อะไรที่ไม่มีในนิยาม"
|
|
}
|
|
|
|
/// <summary>FR-12 ผ่าน detail — หลัง sync แล้วหน้า detail ต้องเห็นค่าล่าสุด (ไม่ใช่ของ v0)</summary>
|
|
[Fact]
|
|
public async Task DetailReflectsLatestSyncedValues()
|
|
{
|
|
var (client, documentId, _) = await CreateTrackedDocumentAsync();
|
|
|
|
using var upload = await client.PostAsync(
|
|
$"api/documents/{documentId}/versions",
|
|
ApiExtensions.VersionForm(
|
|
TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(),
|
|
"""{"employee_name":"ชื่อใหม่สุด","position":"ตำแหน่งใหม่สุด","salary":"1","start_date":"2026-08-20"}"""));
|
|
upload.EnsureSuccessStatusCode();
|
|
|
|
using var detail = await client.GetAsync($"api/documents/{documentId}");
|
|
var body = await detail.Content.ReadFromJsonAsync<DocDetail>(IntegrationTestFixture.Json);
|
|
var byKey = body!.Fields.ToDictionary(f => f.Key);
|
|
|
|
Assert.Equal("ชื่อใหม่สุด", byKey["employee_name"].Value);
|
|
Assert.Equal("ตำแหน่งใหม่สุด", byKey["position"].Value);
|
|
Assert.Equal("1", byKey["salary"].Value);
|
|
}
|
|
|
|
/// <summary>แปลง snapshot JSON (structured {key:{value,missing}}) เป็น dictionary ใช้ใน test</summary>
|
|
private static Dictionary<string, (string Value, bool Missing)> Snapshot(JsonElement fields)
|
|
{
|
|
var result = new Dictionary<string, (string, bool)>();
|
|
foreach (var prop in fields.EnumerateObject())
|
|
result[prop.Name] = (
|
|
prop.Value.GetProperty("value").GetString() ?? "",
|
|
prop.Value.GetProperty("missing").GetBoolean());
|
|
return result;
|
|
}
|
|
}
|