Estimate managment from PostIt #53
4 changed files with 239 additions and 11 deletions
fixes the upload
commit
ef9ee2fb87
|
|
@ -4,11 +4,13 @@ using System.Net.Http.Json;
|
|||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Yavsc.Blogs.Tests.Fixtures;
|
||||
using BlogPostDto = Yavsc.Blogspot.BlogPostDto;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
|
|
@ -450,6 +452,137 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
Assert.Equal(HttpStatusCode.BadRequest, putResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutBlog_from_PostIt_client_with_attachment_returns_204_and_persists_attachment()
|
||||
{
|
||||
// Full PostIt "Save" flow for an existing post with a new
|
||||
// attachment, driven through the real BlogApiClient — the
|
||||
// same code PostIt runs — so any drift between the
|
||||
// client's wire shape and the controller's contract fails
|
||||
// here before it reaches a user:
|
||||
//
|
||||
// 1. BlogsViewModel.SaveAsync (update branch) calls
|
||||
// BlogApiClient.UpdatePostAsync(id, update, attachments)
|
||||
// with a non-empty attachments list →
|
||||
// multipart/form-data PUT built by
|
||||
// CreateMultipartContent: a "blog" field holding the
|
||||
// BlogPostDto serialised camelCase + WhenWritingNull,
|
||||
// plus one "file" part per attachment.
|
||||
// 2. TryAppendAttachmentLinks then appends a markdown
|
||||
// link per attachment to the article, and the VM
|
||||
// issues a second UpdatePostAsync without files →
|
||||
// plain JSON PUT.
|
||||
//
|
||||
// The test asserts the final state: the article carries
|
||||
// the markdown link, the attachment row is persisted, and
|
||||
// the file physically landed under the user-files root
|
||||
// the link points to.
|
||||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
var previousRoot = AbstractFileSystemHelpers.UserFilesDirName;
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "yavsc-blogs-tests-files-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
AbstractFileSystemHelpers.UserFilesDirName = tempRoot;
|
||||
|
||||
try
|
||||
{
|
||||
// BlogApiClient takes the versioned base address
|
||||
// (…/api/v1/) and prepends its "blogspot" prefix —
|
||||
// exactly how PostIt wires it against production.
|
||||
var client = new BlogApiClient(
|
||||
new TestApiTransport(http),
|
||||
_fixture.ApiUrl(""));
|
||||
|
||||
// Seed the post through the same path PostIt uses for
|
||||
// a first Save on a new draft: CreatePostAsync without
|
||||
// files → plain JSON POST.
|
||||
var draft = new BlogPostDto
|
||||
{
|
||||
Title = "Avant PostIt",
|
||||
Article = "Contenu initial.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
var created = await client.CreatePostAsync(draft,
|
||||
ct: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
Assert.NotEqual(0, created!.Id);
|
||||
Assert.Equal("tester", created.AuthorId);
|
||||
|
||||
// Step 1 — the update DTO the VM builds from the
|
||||
// editor buffer (SelectedPost is the read-only hint
|
||||
// for Id, AuthorId, Photo and DateCreated), sent with
|
||||
// one attachment → multipart PUT.
|
||||
var update = new BlogPostDto
|
||||
{
|
||||
Id = created.Id,
|
||||
AuthorId = created.AuthorId,
|
||||
Photo = created.Photo,
|
||||
Title = "Modifié depuis PostIt",
|
||||
Article = "Contenu modifié.",
|
||||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
var attachments = new[]
|
||||
{
|
||||
new BlogUploadFile(
|
||||
"note.txt",
|
||||
System.Text.Encoding.UTF8.GetBytes("payload test"),
|
||||
"text/plain")
|
||||
};
|
||||
|
||||
await client.UpdatePostAsync(created.Id, update, attachments,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Step 2 — TryAppendAttachmentLinks appended a
|
||||
// markdown link to the article (the payload does not
|
||||
// embed Author, so the owner segment falls back to
|
||||
// AuthorId), and the VM PUTs the article again,
|
||||
// without files → JSON path of UpdatePostAsync.
|
||||
var fileUrl = $"{Yavsc.Constants.UserFilesPath}/tester/blogs/{created.Id}/note.txt";
|
||||
var linkUpdate = new BlogPostDto
|
||||
{
|
||||
Id = created.Id,
|
||||
AuthorId = created.AuthorId,
|
||||
Photo = created.Photo,
|
||||
Title = update.Title,
|
||||
Article = update.Article + $"\n\n- [note.txt]({fileUrl})",
|
||||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await client.UpdatePostAsync(created.Id, linkUpdate,
|
||||
ct: TestContext.Current.CancellationToken);
|
||||
|
||||
// Final state, read back through the client.
|
||||
var details = await client.GetPostAsync(created.Id,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(details);
|
||||
Assert.Equal("Modifié depuis PostIt", details!.Title);
|
||||
Assert.Contains($"- [note.txt]({fileUrl})", details.Article);
|
||||
|
||||
// The attachment row is persisted…
|
||||
Assert.True(CountAttachmentsForPost(created.Id) >= 1);
|
||||
|
||||
// …and the file physically landed where the appended
|
||||
// markdown link points:
|
||||
// {UserFilesDirName}/{user}/blogs/{postId}/{fileName}.
|
||||
var expectedFile = Path.Combine(
|
||||
tempRoot, "tester", "blogs", created.Id.ToString(), "note.txt");
|
||||
var written = Directory.Exists(tempRoot)
|
||||
? string.Join(", ", Directory.EnumerateFiles(tempRoot, "*", SearchOption.AllDirectories))
|
||||
: "<none>";
|
||||
Assert.True(File.Exists(expectedFile),
|
||||
$"Expected uploaded file at {expectedFile}. Actually written: {written}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
AbstractFileSystemHelpers.UserFilesDirName = previousRoot;
|
||||
try { Directory.Delete(tempRoot, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
|
||||
{
|
||||
|
|
|
|||
93
src/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs
Normal file
93
src/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace Yavsc.Blogs.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IYavscApiClient"/> transport for integration
|
||||
/// tests: it forwards <see cref="BlogApiClient"/> calls over a
|
||||
/// test-owned <see cref="HttpClient"/> (already authenticated by
|
||||
/// the caller) and applies the contract documented on the
|
||||
/// interface — JSON bodies serialised with web defaults
|
||||
/// (camelCase, case-insensitive reads), and an
|
||||
/// <see cref="HttpRequestException"/> carrying the response body
|
||||
/// on non-2xx statuses so assertion failures stay actionable.
|
||||
///
|
||||
/// <para>Unlike PostIt's <c>YavscApiClient</c>, there is no
|
||||
/// silent-refresh-on-401: the test token never expires mid-test,
|
||||
/// and a 401 is a failure we want surfaced, not retried.</para>
|
||||
///
|
||||
/// The <see cref="HttpClient"/> is owned by the test, not by this
|
||||
/// transport, so <see cref="DisposeAsync"/> is a no-op.
|
||||
/// </summary>
|
||||
public sealed class TestApiTransport : IYavscApiClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public TestApiTransport(HttpClient http)
|
||||
{
|
||||
Http = http ?? throw new ArgumentNullException(nameof(http));
|
||||
}
|
||||
|
||||
public HttpClient Http { get; }
|
||||
|
||||
public Task<T> CallAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
object? body = null,
|
||||
CancellationToken ct = default)
|
||||
=> SendAsync<T>(method, path,
|
||||
body is null ? null : () => JsonContent.Create(body, options: JsonOptions),
|
||||
ct);
|
||||
|
||||
public Task<T> CallAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
Func<HttpContent> contentFactory,
|
||||
CancellationToken ct = default)
|
||||
=> SendAsync<T>(method, path, contentFactory, ct);
|
||||
|
||||
public async Task CallAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
object? body = null,
|
||||
CancellationToken ct = default)
|
||||
=> await SendAsync<object>(method, path,
|
||||
body is null ? null : () => JsonContent.Create(body, options: JsonOptions),
|
||||
ct);
|
||||
|
||||
public async Task CallAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
Func<HttpContent> contentFactory,
|
||||
CancellationToken ct = default)
|
||||
=> await SendAsync<object>(method, path, contentFactory, ct);
|
||||
|
||||
private async Task<T> SendAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
Func<HttpContent>? contentFactory,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path);
|
||||
if (contentFactory is not null)
|
||||
request.Content = contentFactory();
|
||||
|
||||
using var response = await Http.SendAsync(request, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new HttpRequestException(
|
||||
$"HTTP {(int)response.StatusCode} {response.StatusCode} on {method} {path}. Body: {error}");
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
return default!; // 201/204 with an empty body (PUT returns 204).
|
||||
|
||||
return JsonSerializer.Deserialize<T>(payload, JsonOptions)!;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Api.Client\Yavsc.Api.Client.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Blogs\Yavsc.Blogs.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
|
||||
|
|
@ -32,4 +33,4 @@
|
|||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -153,22 +153,23 @@ namespace Yavsc.Server.Helpers
|
|||
return new FsOperationInfo { Done = true };
|
||||
}
|
||||
|
||||
static string ParseFileNameFromDisposition(string disposition)
|
||||
{
|
||||
// form-data_ name=_file__ filename=_Constants.Private.cs_
|
||||
var parts = disposition.Split(' ');
|
||||
var filename = parts[2].Split('=')[1];
|
||||
filename = filename.Substring(1, filename.Length - 2);
|
||||
return filename;
|
||||
}
|
||||
|
||||
public static void AddQuota(this ApplicationUser user, int quota)
|
||||
{
|
||||
user.DiskQuota += quota;
|
||||
}
|
||||
public static FileReceivedInfo ReceiveUserFile(this ApplicationUser user, string root, IFormFile f, string destFileName = null)
|
||||
{
|
||||
return ReceiveUserFile(user, root, f.OpenReadStream(), destFileName ?? ParseFileNameFromDisposition(f.ContentDisposition), f.ContentType, CancellationToken.None);
|
||||
// f.FileName is parsed by ASP.NET Core from the
|
||||
// Content-Disposition header: it handles unquoted
|
||||
// values and the RFC 5987 filename* parameter that
|
||||
// modern HttpClient multipart bodies emit. The
|
||||
// previous hand-rolled parser assumed a quoted
|
||||
// filename in a fixed position and silently dropped
|
||||
// the first character ("note.txt" → "ote.txt"),
|
||||
// which broke the attachment links PostIt appends
|
||||
// to the article. FilterFileName, downstream, still
|
||||
// sanitises any path traversal attempt.
|
||||
return ReceiveUserFile(user, root, f.OpenReadStream(), destFileName ?? f.FileName, f.ContentType, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Reference in a new issue