แก้ตามผลใช้งานจริง 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>
172 lines
9.7 KiB
C#
172 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 browser = new HttpClient();
|
|
using var signedDownload = await browser.GetAsync(link!.Url);
|
|
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 browser.GetAsync(docxLink!.Url);
|
|
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), 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 string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null) => throw new NotSupportedException();
|
|
public Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default) => Task.FromResult(false);
|
|
}
|
|
|
|
}
|