eCourt-Doc-Edit/backend/WordVersioning.Api.IntegrationTests/VersionPdfTests.cs
Suphonchai Phoonsawat ee4d8573ee Phase 12 v9.2: download-first + backend open-links (Word 16.101+ broke ms-word)
การทดสอบ 6 รอบบนเครื่องจริง (Mac, Word 16.101) พิสูจน์ว่า ms-word:ofe
ถูกเมินแบบไม่ deterministic ทุกรูปแบบ (http/https/query/ไม่มี query/
file:// — Word ไม่ยิง request ถึง server เลย เด้งหน้าเลือกไฟล์แทน)
open -a Word <local file> ใช้ได้แต่เรียกจากเว็บไม่ได้ (sandbox +
backend รัน server กลางใน production) → สรุปทางเดียวที่ deterministic
คือ download-first

- Backend: OpenLinkService (HMAC อายุสั้น ValidateOnStart) +
  OpenController /api/open/{t}/{e}/v/{did}/{vid}/file.docx และ /d/{objectKey}
  — ทุกอย่างอยู่ใน path ไม่มี query string (Word เมิน URL มี query),
  [AllowAnonymous] ด้วยลายเซ็นแทน Bearer, stream จาก MinIO + disposition
  ชื่อไทยเหมือนเดิม · download-link + generate-draft ออก open-link
  แทน presigned ของ MinIO (http) · ลบ dead code presigned ทิ้ง
- Web: openDocxUrl = ดาวน์โหลดทันทีทุกครั้ง + ยิง ms-word ควบคู่แบบ
  best-effort + notice "ไฟล์อยู่ Downloads" — เอา heuristic สลับแอป
  ออก (โดนหน้าเลือกไฟล์ของ Word หลอกจนไม่ดาวน์โหลด)
- Spec v9.2 + ตารางทดสอบเครื่องจริงใน acceptance-review + CLAUDE.md
  กฎ 13 (ms-word = best-effort เท่านั้น) + ทางเลือก helper อนาคต
- backend 86 / web 58 / add-in 66 + build ครบ; QA reviewer PASS
  (1 round, minors 4 แก้ครบ)

หมายเหตุ deploy: ต้องตั้ง OpenLinks__Secret (ค่าลับจริง) และ
OpenLinks__BaseUrl (https domain ของ backend) — fail ดังตั้งแต่สตาร์ทถ้าไม่ตั้ง

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 13:39:23 +07:00

171 lines
9.7 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
namespace WordVersioning.Api.IntegrationTests;
[Collection(IntegrationTestCollection.Name)]
public sealed class VersionPdfTests(IntegrationTestFixture fixture)
{
private static readonly byte[] Pdf = "%PDF-1.7\nversion snapshot\n%%EOF"u8.ToArray();
[Fact]
public async Task Upload_WithPdf_PersistsSiblingKey_AndDownloadsExactBytes()
{
using var client = fixture.CreateClient();
var id = Guid.NewGuid();
var clientId = Guid.NewGuid().ToString();
using var response = await client.PostAsync($"api/documents/{id}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "คำร้อง.docx", clientId, pdf: Pdf));
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var result = (await response.ReadJsonAsync<UploadVersionResult>())!;
Assert.True(result.HasPdf);
Assert.Equal(1, result.VersionNumber);
await using var db = fixture.CreateDb();
var row = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.Id == result.VersionId);
Assert.Equal($"{id}/v0001_{clientId}.pdf", row.PdfObjectKey);
var history = await client.GetFromJsonAsync<List<VersionItem>>($"api/documents/{id}/versions");
Assert.True(history!.Single().HasPdf);
using var download = await client.GetAsync($"api/documents/{id}/versions/{result.VersionId}/download?format=PDF");
Assert.Equal(HttpStatusCode.OK, download.StatusCode);
Assert.Equal(Pdf, await download.Content.ReadAsByteArrayAsync());
Assert.Equal("application/pdf", download.Content.Headers.ContentType!.MediaType);
Assert.Equal("คำร้อง.pdf", download.Content.Headers.ContentDisposition!.FileNameStar);
var link = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link?format=pdf");
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็น (HMAC) แทน
using var signedDownload = await anon.GetAsync(new Uri(link!.Url).PathAndQuery);
Assert.Equal(HttpStatusCode.OK, signedDownload.StatusCode);
Assert.Equal(Pdf, await signedDownload.Content.ReadAsByteArrayAsync());
Assert.Equal("inline", signedDownload.Content.Headers.ContentDisposition!.DispositionType);
Assert.Equal("คำร้อง_v1.pdf", signedDownload.Content.Headers.ContentDisposition.FileNameStar);
var docxLink = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link");
using var signedDocx = await anon.GetAsync(new Uri(docxLink!.Url).PathAndQuery);
Assert.Equal(HttpStatusCode.OK, signedDocx.StatusCode);
Assert.Equal("attachment", signedDocx.Content.Headers.ContentDisposition!.DispositionType);
Assert.Equal("คำร้อง_v1.docx", signedDocx.Content.Headers.ContentDisposition.FileNameStar);
}
[Fact]
public async Task Upload_WithoutPdf_HasFalse_AndBothPdfDownloadsReturn404()
{
using var client = fixture.CreateClient();
var id = Guid.NewGuid();
using var response = await client.PostAsync($"api/documents/{id}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString()));
response.EnsureSuccessStatusCode();
var result = (await response.ReadJsonAsync<UploadVersionResult>())!;
Assert.False(result.HasPdf);
await using var db = fixture.CreateDb();
Assert.Null((await db.DocumentVersions.SingleAsync(v => v.Id == result.VersionId)).PdfObjectKey);
foreach (var endpoint in new[] { "download", "download-link" })
{
using var download = await client.GetAsync($"api/documents/{id}/versions/{result.VersionId}/{endpoint}?format=pdf");
Assert.Equal(HttpStatusCode.NotFound, download.StatusCode);
}
}
[Theory]
[InlineData("bad.txt", "application/pdf", false)]
[InlineData("doc.pdf", "text/plain", false)]
[InlineData("doc.pdf", "application/pdf", true)]
public async Task InvalidPdf_Returns400_BeforeCreatingDocument(string name, string type, bool empty)
{
using var client = fixture.CreateClient();
var id = Guid.NewGuid();
using var form = ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString());
var pdf = new ByteArrayContent(empty ? [] : Pdf);
pdf.Headers.ContentType = new MediaTypeHeaderValue(type);
form.Add(pdf, "pdf", name);
using var response = await client.PostAsync($"api/documents/{id}/versions", form);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
await using var db = fixture.CreateDb();
Assert.False(await db.Documents.AnyAsync(d => d.Id == id));
}
[Fact]
public async Task Generate_V0WithoutPdf_ThenUpload_V1WithPdf()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "pdf-gen");
var generated = await TestFlow.GenerateDocumentAsync(client, templateId);
Assert.Equal(0, generated.VersionNumber);
await using var db = fixture.CreateDb();
var row = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.Id == generated.VersionId);
Assert.Null(row.PdfObjectKey);
Assert.StartsWith($"{generated.DocumentId}/v0000_", row.ObjectKey);
using var response = await client.PostAsync($"api/documents/{generated.DocumentId}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(), pdf: Pdf));
response.EnsureSuccessStatusCode();
var result = (await response.ReadJsonAsync<UploadVersionResult>())!;
Assert.Equal(1, result.VersionNumber);
Assert.True(result.HasPdf);
var history = await client.GetFromJsonAsync<List<VersionItem>>($"api/documents/{generated.DocumentId}/versions");
Assert.Equal(new[] { 1, 0 }, history!.Select(v => v.VersionNumber));
Assert.False(history[1].HasPdf);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task StorageFailure_PdfIsBestEffort_DocxStillRollsBack(bool failDocx)
{
await using var db = fixture.CreateDb();
var storage = new FailingStorage(failDocx);
var user = new TestUser();
var controller = new WordVersioning.Api.Controllers.DocumentsController(db, storage,
new WordVersioning.Api.Services.HashService(), null!,
new WordVersioning.Api.Services.FieldSyncService(db, user), null!, user,
Microsoft.Extensions.Options.Options.Create(new WordVersioning.Api.Services.MinioOptions()),
Microsoft.Extensions.Logging.Abstractions.NullLogger<WordVersioning.Api.Controllers.DocumentsController>.Instance);
var id = Guid.NewGuid();
using var docxStream = new MemoryStream(TestDocx.BuildPlainDocx());
using var pdfStream = new MemoryStream(Pdf);
var request = new WordVersioning.Api.Models.UploadVersionRequest
{
File = new Microsoft.AspNetCore.Http.FormFile(docxStream, 0, docxStream.Length, "file", "doc.docx")
{ Headers = new Microsoft.AspNetCore.Http.HeaderDictionary(), ContentType = TestDocx.DocxContentType },
Pdf = new Microsoft.AspNetCore.Http.FormFile(pdfStream, 0, pdfStream.Length, "pdf", "doc.pdf")
{ Headers = new Microsoft.AspNetCore.Http.HeaderDictionary(), ContentType = "application/pdf" },
ClientVersionId = Guid.NewGuid().ToString(),
};
var response = await controller.UploadVersion(id, request, CancellationToken.None);
if (failDocx)
{
Assert.Equal(502, Assert.IsType<Microsoft.AspNetCore.Mvc.ObjectResult>(response).StatusCode);
Assert.False(await db.Documents.AnyAsync(d => d.Id == id));
Assert.False(await db.DocumentVersions.AnyAsync(v => v.DocumentId == id));
Assert.Single(storage.Keys);
}
else
{
var created = Assert.IsType<Microsoft.AspNetCore.Mvc.CreatedAtActionResult>(response);
Assert.False(Assert.IsType<WordVersioning.Api.Models.UploadVersionResponse>(created.Value).HasPdf);
await using var persisted = fixture.CreateDb();
var row = await persisted.DocumentVersions.SingleAsync(v => v.DocumentId == id);
Assert.Null(row.PdfObjectKey);
Assert.Equal(2, storage.Keys.Count);
Assert.EndsWith(".docx", storage.Keys[0]);
Assert.EndsWith(".pdf", storage.Keys[1]);
}
}
private sealed class TestUser : WordVersioning.Api.Services.ICurrentUser
{
public string UserId => "test";
public string? UserName => "test";
}
private sealed class FailingStorage(bool failDocx) : WordVersioning.Api.Services.IObjectStorageService
{
public List<string> Keys { get; } = [];
public Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default)
{
Keys.Add(key);
if (failDocx || key.EndsWith(".pdf")) throw new Amazon.Runtime.AmazonServiceException("Simulated storage failure");
return Task.CompletedTask;
}
public Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default) => throw new NotSupportedException();
public Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default) => Task.FromResult(false);
}
}