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())!; 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>($"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($"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($"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())!; 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())!; Assert.Equal(1, result.VersionNumber); Assert.True(result.HasPdf); var history = await client.GetFromJsonAsync>($"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.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(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(response); Assert.False(Assert.IsType(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 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 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(); } }