diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs index 946d3d28e..6572fdfbb 100644 --- a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -63,17 +63,13 @@ public class BlogAttachmentLinkTests // carrying the file, then — because TryAppendAttachmentLinks // appended a link — a JSON one carrying the final article. var linkPut = recorder.Calls.LastOrDefault( - c => c.method == HttpMethod.Put); + c => c.method == HttpMethod.Put && c.body is BlogPostDto); Assert.NotEqual(default, linkPut); - var sent = Assert.IsType>(linkPut.body); - var multipartContent = Assert.IsType(sent()); + var sent = Assert.IsType(linkPut.body); var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt"; - var sentStringContent = multipartContent.First() as StringContent ; - - var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); - Assert.Contains($"- [note.txt]({expectedUrl})", sentString); + Assert.Contains($"- [note.txt]({expectedUrl})", sent.Article); // The regression shape: a relative, authority-less link. - Assert.DoesNotContain("](/files/", sentString); + Assert.DoesNotContain("](/files/", sent.Article); } } diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs index f86e9a501..78be2e789 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs @@ -116,7 +116,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel // Build a fresh BlogPostDto from the editor buffer on // every Save — we no longer mutate SelectedPost in - // place. The previous behavior copied the buffer + // place. The previous behaviour copied the buffer // (which was a no-op when SelectedPost was null) // back onto the model and relied on a // [Required] violation to surface the missing @@ -160,7 +160,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } else { - TryAppendAttachmentLinks(SelectedPost, attachments); var update = new BlogPostDto { Id = SelectedPost.Id, @@ -171,7 +170,24 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateCreated = SelectedPost.DateCreated, DateModified = DateTime.UtcNow, }; + await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments); + + if (TryAppendAttachmentLinks(SelectedPost, attachments)) + { + var linkUpdate = new BlogPostDto + { + Id = SelectedPost.Id, + AuthorId = SelectedPost.AuthorId, + Photo = SelectedPost.Photo, + Title = DraftTitle, + Article = DraftArticle ?? string.Empty, + DateCreated = SelectedPost.DateCreated, + DateModified = DateTime.UtcNow, + }; + await BlogClient.UpdatePostAsync(SelectedPost.Id, linkUpdate); + } + this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré."); DraftAttachments.Clear(); } @@ -358,7 +374,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel public BlogsViewModel() { - WindowTitle = "Blog"; SettingsModel = new Settings(); Init(SettingsModel); BlogClient = null; @@ -420,7 +435,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel /// public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) { - WindowTitle = "Blog"; SettingsModel = new Settings(); BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; Services = services; diff --git a/src/Yavsc.Api.Client/BlogApiClient.cs b/src/Yavsc.Api.Client/BlogApiClient.cs index 48704dcb9..3de5b37ec 100644 --- a/src/Yavsc.Api.Client/BlogApiClient.cs +++ b/src/Yavsc.Api.Client/BlogApiClient.cs @@ -99,18 +99,7 @@ public sealed class BlogApiClient return await _api.CallAsync(method, path, () => CreateMultipartContent(post, files), ct: ct); } - /// Build the multipart/form-data body PostIt - /// sends when a blog post is saved with one or more - /// attachments: a blog field holding the - /// serialised camelCase with - /// WhenWritingNull, plus one file part per entry - /// in (each carrying its - /// , falling back to - /// application/octet-stream when unset). Exposed as - /// public so the blogs backend tests can assert on the - /// wire shape directly and round-trip it through the live - /// BlogsWebServerFixture host. - public static HttpContent CreateMultipartContent(BlogPostDto post, IReadOnlyCollection files) + private static HttpContent CreateMultipartContent(BlogPostDto post, IReadOnlyCollection files) { var content = new MultipartFormDataContent(); var blogJson = JsonSerializer.Serialize(post, new JsonSerializerOptions diff --git a/src/Yavsc.Blogs.Tests/CreateMultipartContentTests.cs b/src/Yavsc.Blogs.Tests/CreateMultipartContentTests.cs deleted file mode 100644 index 698a2369c..000000000 --- a/src/Yavsc.Blogs.Tests/CreateMultipartContentTests.cs +++ /dev/null @@ -1,255 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; -using Yavsc.Api.Client; -using Yavsc.Models; -using Yavsc.Models.Blog; -using Yavsc.Tests.Shared; -using Yavsc.Blogs.Tests.Fixtures; -using BlogPostDto = Yavsc.Blogspot.BlogPostDto; - -namespace Yavsc.Blogs.Tests; - -/// -/// Direct tests for -/// — the body builder PostIt runs when a post is saved with -/// attachments. Two layers: -/// -/// -/// Shape: call the method with a sample -/// + a couple of -/// s and assert the produced -/// carries a blog -/// part (camelCase JSON, WhenWritingNull so unset graph -/// members are dropped) and one file part per attachment -/// (right filename, right bytes, declared Content-Type, and the -/// application/octet-stream fallback when the type is -/// unset). This is the contract the controller's -/// ReadBlogRequestAsync + AttachFiles rely on; if -/// the wire shape drifts, the round-trip below fails first, but -/// the shape assertions pin down which field broke. -/// Round-trip: POST the very content the -/// method built to the live -/// host (authenticated as tester) and assert 201 Created -/// + the attachment row persisted + the file physically landed -/// under {BlogFilesRoot}/tester/blogs/{id}/{fileName}. -/// This proves the shape the method produces is one the server -/// actually accepts — not just well-formed multipart, but the -/// contract BlogApiController.PostBlog binds. -/// -/// -/// Built on for the round-trip -/// leg (real SQLite FK enforcement, real BlogSpotService, -/// real AddJwtBearer validating -/// tokens). The shape leg needs no host, but the class is -/// [Collection("Yavsc Blogs")] + IClassFixture so the -/// one shared host serves both legs without spinning a second one. -/// -[Collection("Yavsc Blogs")] -public sealed class CreateMultipartContentTests : IClassFixture -{ - private readonly BlogsWebServerFixture _fixture; - - public CreateMultipartContentTests(BlogsWebServerFixture fixture) - { - _fixture = fixture; - } - - /// Reset the database and seed the - /// tester row — required - /// for the round-trip POST, whose BlogPost.AuthorId is - /// a FK to AspNetUsers.Id that SQLite enforces. See - /// for the - /// same rationale. - private void ResetAndSeedDefaultUser() - { - _fixture.ResetDatabase(); - _fixture.SeedUser("tester"); - } - - private HttpClient NewClient(string subject = "tester") - { - var handler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = (_, _, _, _) => true - }; - var http = new HttpClient(handler) - { - BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://"))) - }; - http.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject)); - return http; - } - - private int CountAttachmentsForPost(long postId) - { - using var scope = _fixture.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - return db.BlogAttachedFiles.Count(a => a.PostId == postId); - } - - [Fact] - public async Task Build_produces_blog_part_camelCase_with_WhenWritingNull_and_one_file_part_per_attachment() - { - // A DTO that exercises both branches of the serialiser: - // filled scalar fields (title, article, id, dates, authorId) - // must come through in camelCase; unset graph members - // (Author, Tags, Photo) must be dropped by - // WhenWritingNull — the controller's - // ReadBlogRequestAsync would otherwise receive explicit - // nulls that confuse the resource-based ownership check. - var post = new BlogPostDto - { - Id = 42, - AuthorId = "tester", - Author = null, - Title = "Billet multipart", - Article = "Contenu du billet.", - Photo = null, - Tags = null, - DateCreated = new DateTime(2026, 9, 18, 12, 0, 0, DateTimeKind.Utc), - DateModified = new DateTime(2026, 9, 18, 12, 30, 0, DateTimeKind.Utc) - }; - - var noteBytes = System.Text.Encoding.UTF8.GetBytes("payload test"); - var binBytes = new byte[] { 0x00, 0xFF, 0x42 }; - var files = new BlogUploadFile[] - { - new("note.txt", noteBytes, "text/plain"), - new("bin.dat", binBytes, null) // no ContentType → octet-stream fallback - }; - - var content = BlogApiClient.CreateMultipartContent(post, files); - - // --- Top-level shape: a multipart/form-data body. --- - var form = Assert.IsAssignableFrom(content); - Assert.Equal("multipart/form-data", form.Headers.ContentType?.MediaType); - - // Index parts by their form field name. The "blog" field - // is unique; the "file" field repeats once per attachment - // (distinguished by ContentDisposition.FileName), so a - // flat dictionary would collide — group instead. - var byName = form - .GroupBy(p => p.Headers.ContentDisposition?.Name?.Trim('"')) - .ToDictionary(g => g.Key, g => g.ToList()); - Assert.Contains("blog", byName.Keys); - Assert.Single(byName["blog"]); - - // --- "blog" part: camelCase JSON + WhenWritingNull. --- - var blogJson = await byName["blog"][0].ReadAsStringAsync( - TestContext.Current.CancellationToken); - using var doc = JsonDocument.Parse(blogJson); - var root = doc.RootElement; - - // camelCase: the serialiser used JsonNamingPolicy.CamelCase, - // so the properties land as title/article/id/dateCreated/…. - Assert.True(root.TryGetProperty("title", out var titleEl)); - Assert.Equal(post.Title, titleEl.GetString()); - Assert.True(root.TryGetProperty("article", out var articleEl)); - Assert.Equal(post.Article, articleEl.GetString()); - Assert.True(root.TryGetProperty("id", out var idEl)); - Assert.Equal(post.Id, idEl.GetInt64()); - Assert.True(root.TryGetProperty("authorId", out var authorIdEl)); - Assert.Equal(post.AuthorId, authorIdEl.GetString()); - Assert.True(root.TryGetProperty("dateCreated", out _)); - Assert.True(root.TryGetProperty("dateModified", out _)); - - // WhenWritingNull: the null graph members are omitted, not - // emitted as explicit nulls. - Assert.False(root.TryGetProperty("author", out _), - $"Expected 'author' to be omitted (WhenWritingNull), got: {blogJson}"); - Assert.False(root.TryGetProperty("tags", out _), - $"Expected 'tags' to be omitted (WhenWritingNull), got: {blogJson}"); - Assert.False(root.TryGetProperty("photo", out _), - $"Expected 'photo' to be omitted (WhenWritingNull), got: {blogJson}"); - - // --- "file" parts: one per attachment, right name, type, - // and bytes. The "file" form field carries several parts; - // collect them by filename. --- - var fileParts = form - .Where(p => p.Headers.ContentDisposition?.Name?.Trim('"') == "file") - .ToDictionary(p => p.Headers.ContentDisposition?.FileName?.Trim('"')); - Assert.Equal(2, fileParts.Count); - - var notePart = fileParts["note.txt"]; - Assert.Equal("text/plain", notePart.Headers.ContentType?.MediaType); - Assert.Equal(noteBytes, await notePart.ReadAsByteArrayAsync( - TestContext.Current.CancellationToken)); - - // The attachment with no ContentType falls back to - // application/octet-stream — the branch in - // CreateMultipartContent that guards - // string.IsNullOrWhiteSpace(file.ContentType). - var binPart = fileParts["bin.dat"]; - Assert.Equal("application/octet-stream", binPart.Headers.ContentType?.MediaType); - Assert.Equal(binBytes, await binPart.ReadAsByteArrayAsync( - TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Build_then_POST_round_trips_through_live_host_and_persists_attachment() - { - ResetAndSeedDefaultUser(); - using var http = NewClient(subject: "tester"); - var filesRoot = _fixture.BlogFilesRoot; - - // Create-shape DTO (Id=0 → the controller treats it as a - // create), mirroring what PostIt's MainPageViewModel.Save - // builds for a first Save with an attachment. - var draft = new BlogPostDto - { - Id = 0, - AuthorId = "tester", - Title = "Billet avec pièce jointe", - Article = "Contenu du billet multipart.", - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow - }; - var attachments = new BlogUploadFile[] - { - new("note.txt", System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain") - }; - - // The exact body PostIt would send — built by the method - // under test, not hand-rolled here. - using var content = BlogApiClient.CreateMultipartContent(draft, attachments); - - using var request = new HttpRequestMessage(HttpMethod.Post, _fixture.BlogSpotUrl()) - { - Content = content - }; - var response = await http.SendAsync(request, TestContext.Current.CancellationToken); - - // Dump the body on failure so the assertion message is - // enough to start a fix (the framework default is opaque). - if (response.StatusCode != HttpStatusCode.Created) - { - var body = await response.Content.ReadAsStringAsync( - TestContext.Current.CancellationToken); - Assert.Fail( - $"Expected 201 Created, got {(int)response.StatusCode} {response.StatusCode}. Body: {body}"); - } - - var created = await response.Content.ReadFromJsonAsync( - TestContext.Current.CancellationToken); - Assert.NotNull(created); - Assert.NotEqual(0, created!.Id); - Assert.Equal("tester", created.AuthorId); - - // The attachment row is persisted… - Assert.True(CountAttachmentsForPost(created.Id) >= 1); - - // …and the file physically landed where AttachFiles writes - // it: {BlogFilesRoot}/{user}/blogs/{postId}/{fileName}. - var expectedFile = Path.Combine( - filesRoot, "tester", "blogs", created.Id.ToString(), "note.txt"); - var written = Directory.Exists(filesRoot) - ? string.Join(", ", Directory.EnumerateFiles(filesRoot, "*", SearchOption.AllDirectories)) - : ""; - Assert.True(File.Exists(expectedFile), - $"Expected uploaded file at {expectedFile}. Actually written: {written}"); - } -} \ No newline at end of file