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>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-08 14:01:13 +07:00
parent 618328ffa7
commit 39d610a781
39 changed files with 1387 additions and 212 deletions

View file

@ -10,8 +10,8 @@ namespace WordVersioning.Api.IntegrationTests;
[Collection(IntegrationTestCollection.Name)]
public sealed class FieldSyncTests(IntegrationTestFixture fixture)
{
/// <summary>สร้างเอกสารจาก template ที่ตั้งค่าแล้ว (v1 ค่า DefaultValues) — จุดตั้งต้นของทุก test ในคลาสนี้</summary>
private async Task<(HttpClient Client, Guid DocumentId, Guid V1VersionId)> CreateTrackedDocumentAsync()
/// <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");
@ -20,13 +20,13 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
}
/// <summary>AC-13 — แก้ค่าใน content control แล้ว Save & Version → latest values เปลี่ยนตาม
/// + snapshot ของ version นั้นเก็บค่าใหม่ และ v1 snapshot ยังเก็บค่าเก่าไว้ (diff ได้)</summary>
/// + snapshot ของ version นั้นเก็บค่าใหม่ และ v0 snapshot ยังเก็บค่าเก่าไว้ (diff ได้)</summary>
[Fact]
public async Task UploadWithChangedFields_UpdatesLatestValues_AndSnapshotsPerVersion()
{
var (client, documentId, v1VersionId) = await CreateTrackedDocumentAsync();
var (client, documentId, v0VersionId) = await CreateTrackedDocumentAsync();
var v2Fields = new Dictionary<string, string>
var v1Fields = new Dictionary<string, string>
{
["employee_name"] = "สมหญิง เปลี่ยนชื่อแล้ว",
["position"] = "วิศวกรซอฟต์แวร์", // ไม่เปลี่ยน
@ -37,37 +37,37 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx("แก้ไขใน Word"), "doc.docx", Guid.NewGuid().ToString(),
JsonSerializer.Serialize(v2Fields)));
JsonSerializer.Serialize(v1Fields)));
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
var v2 = await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
var v1 = await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
// latest values: field ที่ "ส่งมา" ถูก update ทุกตัว (แม้ค่าเดิมไม่เปลี่ยน — ไม่มี dirty check)
// จึงชี้ SourceVersionId = v2 ทั้งหมด; ตัวที่ "ไม่ได้ส่ง" เท่านั้นที่คง v1 (ดู test AC-14)
// จึงชี้ 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(v2!.VersionId, values["employee_name"].SourceVersionId);
Assert.Equal(v1!.VersionId, values["employee_name"].SourceVersionId);
Assert.Equal("99000", values["salary"].Value);
Assert.Equal(v2.VersionId, values["salary"].SourceVersionId);
Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value); // ค่าเดิมส่งมาเท่าเดิม → คงเดิมแต่ source ขยับตาม v2
Assert.Equal(v2.VersionId, values["position"].SourceVersionId);
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: v2 = ค่าใหม่, v1 = ค่าเดิม (missing=false ทั้งคู่)
// 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 v1Snapshot = Snapshot(items[0].Fields!.Value);
var v2Snapshot = Snapshot(items[1].Fields!.Value);
Assert.Equal("สมหญิง รักงาน", v1Snapshot["employee_name"].Value);
Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", v2Snapshot["employee_name"].Value);
Assert.Equal("99000", v2Snapshot["salary"].Value);
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));
Assert.All(v2Snapshot.Values, e => Assert.False(e.Missing));
}
/// <summary>AC-14 — ลบ content control แล้ว Save & Version: request สำเร็จ, ค่าเดิมคงอยู่ + mark missing
@ -75,7 +75,7 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
[Fact]
public async Task UploadWithMissingFields_KeepsOldValues_MarksMissing_DoesNotFail()
{
var (client, documentId, v1VersionId) = await CreateTrackedDocumentAsync();
var (client, documentId, v0VersionId) = await CreateTrackedDocumentAsync();
// ผู้ใช้ลบ control ของ position/salary/start_date — เหลือส่ง employee_name เท่านั้น + ส่งเกิน 1 field
using var upload = await client.PostAsync(
@ -87,22 +87,22 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
// policy FR-11: ไม่ fail request
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
// latest values ของ field ที่หาย: คงค่า v1 เดิมไว้ทั้งหมด (รวม SourceVersionId ที่ยังชี้ v1)
// 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(v1VersionId, values["position"].SourceVersionId); // ไม่ถูกแตะเพราะไม่ได้ส่งมา
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 v2: field ที่หาย missing=true พร้อมค่าเดิม, ไม่มี key ของ 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 == 2).Fields!.Value);
var snapshot = Snapshot(items!.Single(i => i.VersionNumber == 1).Fields!.Value);
Assert.False(snapshot["employee_name"].Missing);
Assert.True(snapshot["position"].Missing);
@ -112,7 +112,7 @@ public sealed class FieldSyncTests(IntegrationTestFixture fixture)
Assert.Equal(4, snapshot.Count); // ไม่มี "อะไรที่ไม่มีในนิยาม"
}
/// <summary>FR-12 ผ่าน detail — หลัง sync แล้วหน้า detail ต้องเห็นค่าล่าสุด (ไม่ใช่ของ v1)</summary>
/// <summary>FR-12 ผ่าน detail — หลัง sync แล้วหน้า detail ต้องเห็นค่าล่าสุด (ไม่ใช่ของ v0)</summary>
[Fact]
public async Task DetailReflectsLatestSyncedValues()
{

View file

@ -6,12 +6,12 @@ using WordVersioning.Api.Services;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-10 — generate เอกสารจาก template: ค่าครบ + formatting รักษาไว้ + stamp custom properties
/// + Document/v1/field values ใน flow เดียว (AC-12 ส่วน backend) + FR-12 (detail / รายการเอกสารต่อ template)</summary>
/// + 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_AndCreatesV1()
public async Task Generate_FillsAllFields_PreservesFormatting_StampsProperties_AndCreatesV0()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "generate หลัก");
@ -19,7 +19,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
var doc = await TestFlow.GenerateDocumentAsync(client, templateId);
// response ครบตาม FR-10 + downloadUrl ใช้ได้ทันที
Assert.Equal(1, doc.VersionNumber);
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);
@ -43,7 +43,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
Assert.Equal(AddInMetadata.AddInSignatureGuid, TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn"));
Assert.Equal(doc.DocumentId.ToString(), TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
// (4) สถานะใน Postgres: Document ผูก template + v1 + field values ครบ
// (4) สถานะใน Postgres: Document ผูก template + v0 + field values ครบ
await using var db = fixture.CreateDb();
var document = await db.Documents.AsNoTracking()
.Include(d => d.Versions)
@ -53,7 +53,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
Assert.EndsWith(".docx", document.FileName);
var version = Assert.Single(document.Versions);
Assert.Equal(1, version.VersionNumber);
Assert.Equal(0, version.VersionNumber);
Assert.Equal(fixture.TestUserId, version.CreatedByUserId); // จาก claim sub เท่านั้น
var fieldValues = await db.DocumentFieldValues.AsNoTracking()
@ -109,7 +109,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
};
var doc = await TestFlow.GenerateDocumentAsync(client, templateId, values);
Assert.Equal(1, doc.VersionNumber);
Assert.Equal(0, doc.VersionNumber);
await using var db = fixture.CreateDb();
Assert.False(await db.DocumentFieldValues.AsNoTracking()
@ -158,7 +158,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
Assert.Null(byKey["note"].SourceVersionId);
Assert.Equal("note", byKey["note"].Label);
// def เดิมที่ยังอยู่ (employee_name) — ค่า v1 ยังอยู่ครบ
// def เดิมที่ยังอยู่ (employee_name) — ค่า v0 ยังอยู่ครบ
Assert.Equal("สมหญิง รักงาน", byKey["employee_name"].Value);
Assert.Equal(doc.VersionId, byKey["employee_name"].SourceVersionId);
@ -192,7 +192,7 @@ public sealed class GenerateTests(IntegrationTestFixture fixture)
Assert.Equal(2, items!.Count);
Assert.Equal(firstDoc.DocumentId, items[0].DocumentId); // ล่าสุดก่อน
Assert.Equal(2, items[0].VersionCount);
Assert.Equal(2, items[0].LatestVersionNumber);
Assert.Equal(1, items[0].LatestVersionNumber);
Assert.Equal(secondDoc.DocumentId, items[1].DocumentId);
Assert.Equal(1, items[1].VersionCount);
}

View file

@ -5,11 +5,13 @@ using System.Text.Json;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>DTO ของ response ฝั่ง test — mirror ของ API (deserialize ด้วย camelCase เหมือนที่ API คืน)</summary>
public record UploadVersionResult(Guid VersionId, int VersionNumber);
public record UploadVersionResult(Guid VersionId, int VersionNumber, bool HasPdf);
public record DownloadLinkResult(string Url, DateTimeOffset ExpiresAt);
public record GenerateResult(Guid DocumentId, Guid VersionId, int VersionNumber, string DownloadUrl);
public record VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields, string? Message);
public record VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields, string? Message, bool HasPdf);
public record TemplateFieldItem(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder);
@ -31,7 +33,7 @@ public static class ApiExtensions
{
/// <summary>multipart form ของ POST versions: file + clientVersionId + fields (option) + message (option)</summary>
public static MultipartFormDataContent VersionForm(
byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null, string? message = null)
byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null, string? message = null, byte[]? pdf = null)
{
var form = new MultipartFormDataContent();
var file = new ByteArrayContent(docx);
@ -42,6 +44,12 @@ public static class ApiExtensions
form.Add(new StringContent(fieldsJson), "fields");
if (message is not null)
form.Add(new StringContent(message), "message");
if (pdf is not null)
{
var part = new ByteArrayContent(pdf);
part.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
form.Add(part, "pdf", "document.pdf");
}
return form;
}

View file

@ -146,7 +146,7 @@ public sealed class VersionMessageTests(IntegrationTestFixture fixture)
}
[Fact]
public async Task Generate_WithMessage_CreatesV1WithMessage()
public async Task Generate_WithMessage_CreatesV0WithMessage()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "msg-gen");
@ -157,7 +157,7 @@ public sealed class VersionMessageTests(IntegrationTestFixture fixture)
await using var db = fixture.CreateDb();
var version = await db.DocumentVersions.AsNoTracking()
.SingleAsync(v => v.DocumentId == generated.DocumentId);
Assert.Equal(1, version.VersionNumber);
Assert.Equal(0, version.VersionNumber);
Assert.Equal("ฉบับเสนอลงนามครั้งแรก", version.Message);
using var history = await client.GetAsync($"api/documents/{generated.DocumentId}/versions");

View file

@ -0,0 +1,171 @@
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();
}
}