feat/files-control #52
21 changed files with 659 additions and 34 deletions
Post Files
commit
41d8fad8c3
|
|
@ -162,6 +162,12 @@ public class ActivitiesPageViewModelTests
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||||
|
|
||||||
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync(method, path, (object?)null, ct);
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,12 @@ public class BillingCommandPageViewModelTests
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||||
|
|
||||||
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync(method, path, (object?)null, ct);
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,12 @@ public class BillingQueriesPageViewModelTests
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||||
|
|
||||||
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync(method, path, (object?)null, ct);
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ using Yavsc.Blogspot;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
using PostIt.Views;
|
using PostIt.Views;
|
||||||
|
using PostIt.Views.Blogs;
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
|
@ -72,13 +73,13 @@ public class MainPageButtonsTests
|
||||||
{ }
|
{ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null)
|
private static BlogsViewModel MakeViewModel(BlogPostDto? selectedPost = null)
|
||||||
{
|
{
|
||||||
var api = new ThrowingApi();
|
var api = new ThrowingApi();
|
||||||
var blog = new BlogApiClient(api, "http://localhost/");
|
var blog = new BlogApiClient(api, "http://localhost/");
|
||||||
var circle = new CircleApiClient(api, "http://localhost/");
|
var circle = new CircleApiClient(api, "http://localhost/");
|
||||||
var acl = new BlogAclApiClient(api, "http://localhost/");
|
var acl = new BlogAclApiClient(api, "http://localhost/");
|
||||||
// Minimal DI graph: only what MainPageViewModel resolves
|
// Minimal DI graph: only what BlogsViewModel resolves
|
||||||
// when the user clicks a navigation button. Today that's
|
// when the user clicks a navigation button. Today that's
|
||||||
// SignaturePageViewModel / CirclesPageViewModel / ACL
|
// SignaturePageViewModel / CirclesPageViewModel / ACL
|
||||||
// dependencies. The graph intentionally stays local to this
|
// dependencies. The graph intentionally stays local to this
|
||||||
|
|
@ -93,7 +94,7 @@ public class MainPageButtonsTests
|
||||||
services.AddTransient<SignaturePage>();
|
services.AddTransient<SignaturePage>();
|
||||||
services.AddTransient<CirclesPage>();
|
services.AddTransient<CirclesPage>();
|
||||||
services.AddTransient<PostAclDialog>();
|
services.AddTransient<PostAclDialog>();
|
||||||
var vm = new MainViewModel(blog, services: services.BuildServiceProvider());
|
var vm = new BlogsViewModel(blog, services: services.BuildServiceProvider());
|
||||||
if (selectedPost is not null) vm.SelectedPost = selectedPost;
|
if (selectedPost is not null) vm.SelectedPost = selectedPost;
|
||||||
return vm;
|
return vm;
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +102,7 @@ public class MainPageButtonsTests
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mount a real <see cref="MainView"/> (as
|
/// Mount a real <see cref="MainView"/> (as
|
||||||
/// <c>SessionStatusBannerTests</c> does), push a
|
/// <c>SessionStatusBannerTests</c> does), push a
|
||||||
/// <see cref="MainPage"/> with the given VM onto
|
/// <see cref="BlogsPage"/> with the given VM onto
|
||||||
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
|
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
|
||||||
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
|
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
|
||||||
/// nav stack before the test tries to interact with its
|
/// nav stack before the test tries to interact with its
|
||||||
|
|
@ -109,10 +110,10 @@ public class MainPageButtonsTests
|
||||||
/// realised and <c>KeyPressQwerty</c> has a real
|
/// realised and <c>KeyPressQwerty</c> has a real
|
||||||
/// <see cref="TopLevel"/> to dispatch against.
|
/// <see cref="TopLevel"/> to dispatch against.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static (MainView window, MainPage page) MountMainPage(MainViewModel vm)
|
private static (MainView window, BlogsPage page) MountMainPage(BlogsViewModel vm)
|
||||||
{
|
{
|
||||||
var window = new MainView();
|
var window = new MainView();
|
||||||
var page = new MainPage { DataContext = vm };
|
var page = new BlogsPage { DataContext = vm };
|
||||||
var app = (PostIt.App)Application.Current!;
|
var app = (PostIt.App)Application.Current!;
|
||||||
app.AttachMainWindow(window);
|
app.AttachMainWindow(window);
|
||||||
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
|
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
|
||||||
|
|
@ -203,7 +204,7 @@ public class MainPageButtonsTests
|
||||||
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
|
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
|
||||||
{
|
{
|
||||||
// Arrange: the "[DEV] Signature" button is bound to the
|
// Arrange: the "[DEV] Signature" button is bound to the
|
||||||
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
|
// BlogsViewModel.OpenSignatureDevCommand [RelayCommand].
|
||||||
// The click must push SignaturePage on top of NavRoot.
|
// The click must push SignaturePage on top of NavRoot.
|
||||||
// The ServiceCollection registered in MakeViewModel provides
|
// The ServiceCollection registered in MakeViewModel provides
|
||||||
// SignaturePageViewModel so the command can resolve it via
|
// SignaturePageViewModel so the command can resolve it via
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ using Yavsc.Blogspot;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
using PostIt.Views;
|
using PostIt.Views;
|
||||||
|
using PostIt.Views.Blogs;
|
||||||
namespace PostIt.Tests;
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Headless UI tests for the "Save" flow in <see cref="MainPage"/>.
|
/// Headless UI tests for the "Save" flow in <see cref="BlogsPage"/>.
|
||||||
/// The pattern is the one <c>SessionStatusBannerTests</c>
|
/// The pattern is the one <c>SessionStatusBannerTests</c>
|
||||||
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
|
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
|
||||||
/// hosting the page (via a <see cref="Frame"/> because
|
/// hosting the page (via a <see cref="Frame"/> because
|
||||||
|
|
@ -40,9 +41,9 @@ public class MainPageSaveTests
|
||||||
var recorder = new CallRecorder();
|
var recorder = new CallRecorder();
|
||||||
var api = new RecordingYavscApiClient(recorder);
|
var api = new RecordingYavscApiClient(recorder);
|
||||||
var blog = new BlogApiClient(api, "http://localhost/");
|
var blog = new BlogApiClient(api, "http://localhost/");
|
||||||
var viewModel = new MainViewModel(blog);
|
var viewModel = new BlogsViewModel(blog);
|
||||||
|
|
||||||
var page = new MainPage { DataContext = viewModel };
|
var page = new BlogsPage { DataContext = viewModel };
|
||||||
// MainPage is a ContentPage (a Page, not a Control), so it
|
// MainPage is a ContentPage (a Page, not a Control), so it
|
||||||
// must be hosted in a navigation surface. The production
|
// must be hosted in a navigation surface. The production
|
||||||
// MainWindow.axaml uses NavigationPage, and the API is the
|
// MainWindow.axaml uses NavigationPage, and the API is the
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,12 @@ public class PostAclDialogTests
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||||
|
|
||||||
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync(method, path, (object?)null, ct);
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,12 @@ public class PostItViewModelTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SearchCommand_filters_posts_by_title_article_or_author()
|
public void SearchCommand_filters_posts_by_title_article_or_author()
|
||||||
{
|
{
|
||||||
// MainPageViewModel no longer owns a BlogApiClient instance by
|
// BlogsViewModel no longer owns a BlogApiClient instance by
|
||||||
// default; tests construct one with a fake YavscApiClient that
|
// default; tests construct one with a fake YavscApiClient that
|
||||||
// throws on any call (we never call the API in this test).
|
// throws on any call (we never call the API in this test).
|
||||||
var fakeApi = new ThrowingYavscApiClient();
|
var fakeApi = new ThrowingYavscApiClient();
|
||||||
var blog = new BlogApiClient(fakeApi, "http://localhost/");
|
var blog = new BlogApiClient(fakeApi, "http://localhost/");
|
||||||
var viewModel = new MainViewModel(blog);
|
var viewModel = new BlogsViewModel(blog);
|
||||||
|
|
||||||
viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
|
viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
|
||||||
viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
|
viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
|
||||||
|
|
@ -60,7 +60,7 @@ public class PostItViewModelTests
|
||||||
{
|
{
|
||||||
var api = new RecordingPublishApi();
|
var api = new RecordingPublishApi();
|
||||||
var blog = new BlogApiClient(api, "http://localhost/");
|
var blog = new BlogApiClient(api, "http://localhost/");
|
||||||
var viewModel = new MainViewModel(blog);
|
var viewModel = new BlogsViewModel(blog);
|
||||||
|
|
||||||
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
|
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
|
||||||
|
|
||||||
|
|
@ -146,6 +146,12 @@ public class PostItViewModelTests
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||||
|
|
||||||
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync(method, path, (object?)null, ct);
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,12 @@ public class RdvPageHeadlessTests
|
||||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||||
=> Task.CompletedTask;
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||||
|
|
||||||
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync(method, path, (object?)null, ct);
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,28 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
object? body = null,
|
object? body = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
|
using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false);
|
||||||
|
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||||
|
var dto = await JsonSerializer.DeserializeAsync<T>(stream,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
|
||||||
|
return dto!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call a multipart endpoint, transparently refreshing the token if needed.
|
||||||
|
/// </summary>
|
||||||
|
public virtual async Task<T> CallAsync<T>(
|
||||||
|
HttpMethod method,
|
||||||
|
string path,
|
||||||
|
Func<HttpContent> contentFactory,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (contentFactory is null)
|
||||||
|
throw new ArgumentNullException(nameof(contentFactory));
|
||||||
|
|
||||||
|
using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false);
|
||||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||||
|
|
@ -217,7 +238,23 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
object? body = null,
|
object? body = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
|
using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false);
|
||||||
|
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call a multipart endpoint that returns no useful body (DELETE, etc.).
|
||||||
|
/// </summary>
|
||||||
|
public async Task CallAsync(
|
||||||
|
HttpMethod method,
|
||||||
|
string path,
|
||||||
|
Func<HttpContent> contentFactory,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (contentFactory is null)
|
||||||
|
throw new ArgumentNullException(nameof(contentFactory));
|
||||||
|
|
||||||
|
using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false);
|
||||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,7 +269,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
=> CallAsync(method, path, body: null, ct);
|
=> CallAsync(method, path, body: null, ct);
|
||||||
|
|
||||||
private async Task<HttpResponseMessage> SendAsync(
|
private async Task<HttpResponseMessage> SendAsync(
|
||||||
HttpMethod method, string path, object? body, CancellationToken ct)
|
HttpMethod method, string path, Func<HttpContent>? contentFactory, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_tokens is null)
|
if (_tokens is null)
|
||||||
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
|
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
|
||||||
|
|
@ -240,8 +277,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
|
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
using var req = new HttpRequestMessage(method, path);
|
using var req = new HttpRequestMessage(method, path);
|
||||||
if (body is not null)
|
if (contentFactory is not null)
|
||||||
req.Content = JsonContent.Create(body);
|
req.Content = contentFactory();
|
||||||
var response = await Http.SendAsync(req, ct).ConfigureAwait(false);
|
var response = await Http.SendAsync(req, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
|
@ -252,8 +289,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
using var retry = new HttpRequestMessage(method, path);
|
using var retry = new HttpRequestMessage(method, path);
|
||||||
if (body is not null)
|
if (contentFactory is not null)
|
||||||
retry.Content = JsonContent.Create(body);
|
retry.Content = contentFactory();
|
||||||
response = await Http.SendAsync(retry, ct).ConfigureAwait(false);
|
response = await Http.SendAsync(retry, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
||||||
? $"Demandes en cours ({Form.Title})"
|
? $"Demandes en cours ({Form.Title})"
|
||||||
: $"Commandes {Form.Title}";
|
: $"Commandes {Form.Title}";
|
||||||
public string ContextLabel => $"{Performer.UserName} · {Activity.Name}";
|
public string ContextLabel => $"{Performer.UserName} · {Activity.Name}";
|
||||||
public bool CanOpenDetails => true;
|
public bool CanOpenDetails => !IsReadOnly;
|
||||||
|
|
||||||
public override bool CanNavigateNext
|
public override bool CanNavigateNext
|
||||||
{
|
{
|
||||||
|
|
@ -75,7 +75,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
||||||
|
|
||||||
public Task InitializeAsync() => RefreshAsync();
|
public Task InitializeAsync() => RefreshAsync();
|
||||||
|
|
||||||
private bool CanOpenSelectedQuery() => SelectedQuery is not null;
|
private bool CanOpenSelectedQuery() => CanOpenDetails && SelectedQuery is not null;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public async Task RefreshAsync()
|
public async Task RefreshAsync()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
|
@ -8,6 +9,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Yavsc.Blogspot;
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
|
using Yavsc.Abstract.Files;
|
||||||
using PostIt.Helpers;
|
using PostIt.Helpers;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
@ -66,6 +68,9 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial ObservableCollection<BlogPostDto> FilteredPosts { get; set; }
|
public partial ObservableCollection<BlogPostDto> FilteredPosts { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<BlogUploadFile> DraftAttachments { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial BlogPostDto? SelectedPost { get; set; }
|
public partial BlogPostDto? SelectedPost { get; set; }
|
||||||
|
|
||||||
|
|
@ -113,6 +118,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
|
|
||||||
await ExecuteAsync(async () =>
|
await ExecuteAsync(async () =>
|
||||||
{
|
{
|
||||||
|
var attachments = DraftAttachments.ToArray();
|
||||||
|
|
||||||
// Build a fresh BlogPostDto from the editor buffer on
|
// Build a fresh BlogPostDto from the editor buffer on
|
||||||
// every Save — we no longer mutate SelectedPost in
|
// every Save — we no longer mutate SelectedPost in
|
||||||
// place. The previous behaviour copied the buffer
|
// place. The previous behaviour copied the buffer
|
||||||
|
|
@ -133,11 +140,28 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
DateModified = DateTime.UtcNow,
|
DateModified = DateTime.UtcNow,
|
||||||
IsPublished = DraftIsPublished
|
IsPublished = DraftIsPublished
|
||||||
};
|
};
|
||||||
var created = await BlogClient!.CreatePostAsync(draft);
|
var created = await BlogClient!.CreatePostAsync(draft, attachments);
|
||||||
if (created is not null)
|
if (created is not null)
|
||||||
{
|
{
|
||||||
SelectedPost = created;
|
SelectedPost = created;
|
||||||
|
|
||||||
|
if (TryAppendAttachmentLinks(created, attachments))
|
||||||
|
{
|
||||||
|
var linkUpdate = new BlogPostDto
|
||||||
|
{
|
||||||
|
Id = created.Id,
|
||||||
|
AuthorId = created.AuthorId,
|
||||||
|
Photo = created.Photo,
|
||||||
|
Title = DraftTitle,
|
||||||
|
Article = DraftArticle ?? string.Empty,
|
||||||
|
DateCreated = created.DateCreated,
|
||||||
|
DateModified = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
await BlogClient.UpdatePostAsync(created.Id, linkUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
this.SetInfoStatus($"Billet {created.Id} créé.");
|
this.SetInfoStatus($"Billet {created.Id} créé.");
|
||||||
|
DraftAttachments.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -152,8 +176,26 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
DateCreated = SelectedPost.DateCreated,
|
DateCreated = SelectedPost.DateCreated,
|
||||||
DateModified = DateTime.UtcNow,
|
DateModified = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update);
|
|
||||||
|
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é.");
|
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
|
||||||
|
DraftAttachments.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
await RefreshPostsAsync();
|
await RefreshPostsAsync();
|
||||||
|
|
@ -347,6 +389,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
{
|
{
|
||||||
Posts = new ObservableCollection<BlogPostDto>();
|
Posts = new ObservableCollection<BlogPostDto>();
|
||||||
FilteredPosts = new ObservableCollection<BlogPostDto>();
|
FilteredPosts = new ObservableCollection<BlogPostDto>();
|
||||||
|
DraftAttachments = new ObservableCollection<BlogUploadFile>();
|
||||||
SelectedPost = null;
|
SelectedPost = null;
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
this.SetInfoStatus("Prêt.");
|
this.SetInfoStatus("Prêt.");
|
||||||
|
|
@ -427,6 +470,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
// Mirror publication state too. Defaults to false on
|
// Mirror publication state too. Defaults to false on
|
||||||
// null selection so a fresh draft starts unpublished.
|
// null selection so a fresh draft starts unpublished.
|
||||||
DraftIsPublished = value?.IsPublished ?? false;
|
DraftIsPublished = value?.IsPublished ?? false;
|
||||||
|
DraftAttachments.Clear();
|
||||||
UpdateCommandStates();
|
UpdateCommandStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -508,4 +552,55 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
IsLoaded = true;
|
IsLoaded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> attachments)
|
||||||
|
{
|
||||||
|
if (attachments.Count == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var ownerSegment = post.Author?.UserName;
|
||||||
|
if (string.IsNullOrWhiteSpace(ownerSegment))
|
||||||
|
ownerSegment = post.AuthorId;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(ownerSegment))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var article = DraftArticle ?? string.Empty;
|
||||||
|
var links = new List<string>();
|
||||||
|
|
||||||
|
foreach (var attachment in attachments)
|
||||||
|
{
|
||||||
|
var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}";
|
||||||
|
var fileUrl = ResolveUserFileUrl(relativePath);
|
||||||
|
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
|
||||||
|
|
||||||
|
if (!article.Contains(markdownLine, StringComparison.Ordinal))
|
||||||
|
links.Add(markdownLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (links.Count == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var prefix = article.Length == 0
|
||||||
|
? ""
|
||||||
|
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
|
||||||
|
|
||||||
|
DraftArticle = article + prefix + string.Join("\n", links);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveUserFileUrl(string relativePath)
|
||||||
|
{
|
||||||
|
var authority = Settings?.Authentication?.Authority;
|
||||||
|
if (!string.IsNullOrWhiteSpace(authority)
|
||||||
|
&& Uri.TryCreate(authority, UriKind.Absolute, out var baseUri))
|
||||||
|
{
|
||||||
|
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapePathSegment(string segment)
|
||||||
|
=> Uri.EscapeDataString(segment);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@
|
||||||
<Button Command="{Binding SearchAsync}" Content="🔍 Filter" />
|
<Button Command="{Binding SearchAsync}" Content="🔍 Filter" />
|
||||||
<Button Command="{Binding SaveAsync}" Content="Save" />
|
<Button Command="{Binding SaveAsync}" Content="Save" />
|
||||||
<Button Command="{Binding DeleteAsync}" Content="Delete" />
|
<Button Command="{Binding DeleteAsync}" Content="Delete" />
|
||||||
|
<Button x:Name="AddAttachmentButton"
|
||||||
|
Content="Ajouter fichiers"
|
||||||
|
Click="AddAttachment_Click" />
|
||||||
<Button x:Name="ManageAclButton"
|
<Button x:Name="ManageAclButton"
|
||||||
Command="{Binding ManageAclAsync}"
|
Command="{Binding ManageAclAsync}"
|
||||||
Content="ACL" />
|
Content="ACL" />
|
||||||
|
|
@ -102,10 +105,22 @@
|
||||||
Text="{Binding DraftArticle, Mode=TwoWay}"
|
Text="{Binding DraftArticle, Mode=TwoWay}"
|
||||||
MinHeight="320"
|
MinHeight="320"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch">
|
VerticalAlignment="Stretch" />
|
||||||
</TextBox>
|
|
||||||
|
|
||||||
<postitControls:StatusBar Grid.Row="3"
|
<Border Grid.Row="3" BorderBrush="LightGray" BorderThickness="1" Padding="8">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Pièces jointes" FontWeight="SemiBold" />
|
||||||
|
<ItemsControl ItemsSource="{Binding DraftAttachments}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding FileName}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<postitControls:StatusBar Grid.Row="4"
|
||||||
DataContext="{Binding ActionStatus}" />
|
DataContext="{Binding ActionStatus}" />
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
|
|
||||||
namespace PostIt.Views.Blogs;
|
namespace PostIt.Views.Blogs;
|
||||||
|
|
@ -22,4 +25,47 @@ public partial class BlogsPage : ContentPage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void AddAttachment_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
if (topLevel is null || DataContext is not ViewModels.BlogsViewModel vm)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Choisir des fichiers à joindre au billet",
|
||||||
|
AllowMultiple = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var uploads = new System.Collections.Generic.List<Yavsc.Api.Client.BlogUploadFile>(files.Count);
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
await using var stream = await file.OpenReadAsync();
|
||||||
|
using var memory = new MemoryStream();
|
||||||
|
await stream.CopyToAsync(memory);
|
||||||
|
uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
vm.DraftAttachments.Clear();
|
||||||
|
foreach (var upload in uploads)
|
||||||
|
vm.DraftAttachments.Add(upload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetMimeType(string fileName)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
|
||||||
|
return ext switch
|
||||||
|
{
|
||||||
|
".png" => "image/png",
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".pdf" => "application/pdf",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
src/Yavsc.Abstract/Files/FileServerUrlHelpers.cs
Normal file
67
src/Yavsc.Abstract/Files/FileServerUrlHelpers.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
namespace Yavsc.Abstract.Files;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helpers pour dériver les URL publiques des fichiers statiques à partir
|
||||||
|
/// d'une URL d'autorité OIDC ou d'un autre point d'entrée racine.
|
||||||
|
/// </summary>
|
||||||
|
public static class FileServerUrlHelpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Dérive la racine publique des fichiers utilisateur en alignant
|
||||||
|
/// le chemin sur <see cref="Yavsc.Constants.UserFilesPath"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authorityBaseUrl">
|
||||||
|
/// URL absolue de base, typiquement l'autorité OIDC de Yavsc.Org.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>Une URL absolue pointant vers la racine des fichiers utilisateur.</returns>
|
||||||
|
public static Uri GetUserFilesBaseUri(Uri authorityBaseUrl)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(authorityBaseUrl);
|
||||||
|
|
||||||
|
if (!authorityBaseUrl.IsAbsoluteUri)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"The authority base URL must be absolute.",
|
||||||
|
nameof(authorityBaseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseString = authorityBaseUrl.GetLeftPart(UriPartial.Authority);
|
||||||
|
return new Uri(new Uri(baseString, UriKind.Absolute), EnsureTrailingSlash(Yavsc.Constants.UserFilesPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dérive la racine publique des fichiers utilisateur en alignant
|
||||||
|
/// le chemin sur <see cref="Yavsc.Constants.UserFilesPath"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authorityBaseUrl">
|
||||||
|
/// URL absolue de base, typiquement l'autorité OIDC de Yavsc.Org.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>Une URL absolue pointant vers la racine des fichiers utilisateur.</returns>
|
||||||
|
public static Uri GetUserFilesBaseUri(string authorityBaseUrl)
|
||||||
|
=> GetUserFilesBaseUri(new Uri(authorityBaseUrl, UriKind.Absolute));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Construit l'URL d'un fichier utilisateur à partir de la base d'autorité
|
||||||
|
/// et d'un chemin relatif sous la racine des fichiers.
|
||||||
|
/// </summary>
|
||||||
|
public static Uri GetUserFilesUri(Uri authorityBaseUrl, string relativePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);
|
||||||
|
|
||||||
|
var baseUri = GetUserFilesBaseUri(authorityBaseUrl);
|
||||||
|
return new Uri(baseUri, NormalizeRelativePath(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Construit l'URL d'un fichier utilisateur à partir de la base d'autorité
|
||||||
|
/// et d'un chemin relatif sous la racine des fichiers.
|
||||||
|
/// </summary>
|
||||||
|
public static Uri GetUserFilesUri(string authorityBaseUrl, string relativePath)
|
||||||
|
=> GetUserFilesUri(new Uri(authorityBaseUrl, UriKind.Absolute), relativePath);
|
||||||
|
|
||||||
|
private static string EnsureTrailingSlash(string path)
|
||||||
|
=> path.EndsWith("/", StringComparison.Ordinal) ? path : path + "/";
|
||||||
|
|
||||||
|
private static string NormalizeRelativePath(string relativePath)
|
||||||
|
=> relativePath.TrimStart('/');
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Yavsc.Blogspot;
|
using Yavsc.Blogspot;
|
||||||
|
|
@ -62,11 +64,18 @@ public sealed class BlogApiClient
|
||||||
public Task<BlogPostDto?> GetPostAsync(long id, CancellationToken ct = default)
|
public Task<BlogPostDto?> GetPostAsync(long id, CancellationToken ct = default)
|
||||||
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
|
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
|
||||||
|
|
||||||
public Task<BlogPostDto?> CreatePostAsync(BlogPostDto post, CancellationToken ct = default)
|
public Task<BlogPostDto?> CreatePostAsync(
|
||||||
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
|
BlogPostDto post,
|
||||||
|
IReadOnlyCollection<BlogUploadFile>? files = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> SendPostAsync(HttpMethod.Post, _pathPrefix, post, files, ct);
|
||||||
|
|
||||||
public Task UpdatePostAsync(long id, BlogPostDto post, CancellationToken ct = default)
|
public Task UpdatePostAsync(
|
||||||
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct);
|
long id,
|
||||||
|
BlogPostDto post,
|
||||||
|
IReadOnlyCollection<BlogUploadFile>? files = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> SendPostAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", post, files, ct);
|
||||||
|
|
||||||
public Task DeletePostAsync(long id, CancellationToken ct = default)
|
public Task DeletePostAsync(long id, CancellationToken ct = default)
|
||||||
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct);
|
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct);
|
||||||
|
|
@ -81,4 +90,39 @@ public sealed class BlogApiClient
|
||||||
public Task SetPublishAsync(long id, bool publish, CancellationToken ct = default)
|
public Task SetPublishAsync(long id, bool publish, CancellationToken ct = default)
|
||||||
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}/publish",
|
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}/publish",
|
||||||
body: new { publish }, ct: ct);
|
body: new { publish }, ct: ct);
|
||||||
|
|
||||||
|
private Task<BlogPostDto?> SendPostAsync(
|
||||||
|
HttpMethod method,
|
||||||
|
string path,
|
||||||
|
BlogPostDto post,
|
||||||
|
IReadOnlyCollection<BlogUploadFile>? files,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (files is null || files.Count == 0)
|
||||||
|
return _api.CallAsync<BlogPostDto?>(method, path, body: post, ct: ct);
|
||||||
|
|
||||||
|
return _api.CallAsync<BlogPostDto?>(method, path, () => CreateMultipartContent(post, files), ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpContent CreateMultipartContent(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> files)
|
||||||
|
{
|
||||||
|
var content = new MultipartFormDataContent();
|
||||||
|
var blogJson = JsonSerializer.Serialize(post, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
});
|
||||||
|
|
||||||
|
content.Add(new StringContent(blogJson), "blog");
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
var fileContent = new ByteArrayContent(file.Content);
|
||||||
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||||
|
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
|
||||||
|
content.Add(fileContent, "file", file.FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
src/Yavsc.Api.Client/BlogUploadFile.cs
Normal file
6
src/Yavsc.Api.Client/BlogUploadFile.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buffered file payload for multipart blog uploads.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record BlogUploadFile(string FileName, byte[] Content, string? ContentType = null);
|
||||||
|
|
@ -53,10 +53,24 @@ public interface IYavscApiClient : IAsyncDisposable
|
||||||
object? body = null,
|
object? body = null,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Call a multipart endpoint with a typed return value.</summary>
|
||||||
|
Task<T> CallAsync<T>(
|
||||||
|
HttpMethod method,
|
||||||
|
string path,
|
||||||
|
Func<HttpContent> contentFactory,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Call a JSON endpoint that returns no useful body (DELETE, 204, etc.).</summary>
|
/// <summary>Call a JSON endpoint that returns no useful body (DELETE, 204, etc.).</summary>
|
||||||
Task CallAsync(
|
Task CallAsync(
|
||||||
HttpMethod method,
|
HttpMethod method,
|
||||||
string path,
|
string path,
|
||||||
object? body = null,
|
object? body = null,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Call a multipart endpoint that returns no useful body.</summary>
|
||||||
|
Task CallAsync(
|
||||||
|
HttpMethod method,
|
||||||
|
string path,
|
||||||
|
Func<HttpContent> contentFactory,
|
||||||
|
CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Blog;
|
using Yavsc.Models.Blog;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
@ -88,6 +90,13 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int CountAttachmentsForPost(long postId)
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
return db.BlogAttachedFiles.Count(a => a.PostId == postId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetBlogs_returns_200_with_empty_list_when_no_posts()
|
public async Task GetBlogs_returns_200_with_empty_list_when_no_posts()
|
||||||
{
|
{
|
||||||
|
|
@ -321,6 +330,126 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
Assert.Equal("Après", doc.RootElement[0].GetProperty("title").GetString());
|
Assert.Equal("Après", doc.RootElement[0].GetProperty("title").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PutBlog_multipart_with_blog_and_file_returns_204_and_persists_attachment()
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
var draft = new BlogPost
|
||||||
|
{
|
||||||
|
Id = 0,
|
||||||
|
Title = "Initial",
|
||||||
|
AuthorId = "tester",
|
||||||
|
Article = "Contenu initial.",
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DateModified = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var postResponse = await http.PostAsJsonAsync(
|
||||||
|
_fixture.BlogSpotUrl(),
|
||||||
|
draft,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||||
|
|
||||||
|
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
|
||||||
|
TestContext.Current.CancellationToken))!;
|
||||||
|
|
||||||
|
var update = new BlogPost
|
||||||
|
{
|
||||||
|
Id = created.Id,
|
||||||
|
Title = "Mis a jour via multipart",
|
||||||
|
AuthorId = created.AuthorId,
|
||||||
|
Article = "Contenu mis a jour.",
|
||||||
|
DateCreated = created.DateCreated,
|
||||||
|
DateModified = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var form = new MultipartFormDataContent();
|
||||||
|
form.Add(new StringContent(JsonSerializer.Serialize(update)), "blog");
|
||||||
|
|
||||||
|
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
|
||||||
|
var fileContent = new ByteArrayContent(fileBytes);
|
||||||
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||||
|
form.Add(fileContent, "file", "note.txt");
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(
|
||||||
|
HttpMethod.Put,
|
||||||
|
_fixture.BlogSpotUrl() + $"/{created.Id}")
|
||||||
|
{
|
||||||
|
Content = form
|
||||||
|
};
|
||||||
|
|
||||||
|
var putResponse = await http.SendAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
|
||||||
|
|
||||||
|
var detailsResponse = await http.GetAsync(
|
||||||
|
_fixture.BlogSpotUrl() + $"/{created.Id}",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, detailsResponse.StatusCode);
|
||||||
|
|
||||||
|
using var detailsDoc = JsonDocument.Parse(
|
||||||
|
await detailsResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||||
|
Assert.Equal("Mis a jour via multipart", detailsDoc.RootElement.GetProperty("title").GetString());
|
||||||
|
|
||||||
|
Assert.True(CountAttachmentsForPost(created.Id) >= 1);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
AbstractFileSystemHelpers.UserFilesDirName = previousRoot;
|
||||||
|
try { Directory.Delete(tempRoot, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PutBlog_multipart_without_blog_field_returns_400()
|
||||||
|
{
|
||||||
|
ResetAndSeedDefaultUser();
|
||||||
|
using var http = NewClient(subject: "tester");
|
||||||
|
|
||||||
|
var draft = new BlogPost
|
||||||
|
{
|
||||||
|
Id = 0,
|
||||||
|
Title = "Initial",
|
||||||
|
AuthorId = "tester",
|
||||||
|
Article = "Contenu initial.",
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DateModified = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var postResponse = await http.PostAsJsonAsync(
|
||||||
|
_fixture.BlogSpotUrl(),
|
||||||
|
draft,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||||
|
|
||||||
|
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
|
||||||
|
TestContext.Current.CancellationToken))!;
|
||||||
|
|
||||||
|
var form = new MultipartFormDataContent();
|
||||||
|
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
|
||||||
|
var fileContent = new ByteArrayContent(fileBytes);
|
||||||
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||||
|
form.Add(fileContent, "file", "note.txt");
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(
|
||||||
|
HttpMethod.Put,
|
||||||
|
_fixture.BlogSpotUrl() + $"/{created.Id}")
|
||||||
|
{
|
||||||
|
Content = form
|
||||||
|
};
|
||||||
|
|
||||||
|
var putResponse = await http.SendAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, putResponse.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
|
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Text.Json;
|
||||||
using Yavsc.Blogspot;
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Server.Exceptions;
|
using Yavsc.Server.Exceptions;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
@ -53,8 +55,14 @@ namespace Yavsc.Blogs.Controllers
|
||||||
|
|
||||||
// PUT: api/v1/blogspot/5
|
// PUT: api/v1/blogspot/5
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
public async Task<IActionResult> PutBlog(long id, [FromBody] Models.Blog.BlogPost blog)
|
public async Task<IActionResult> PutBlog(long id)
|
||||||
{
|
{
|
||||||
|
var blog = await ReadPutBlogRequestAsync();
|
||||||
|
if (blog is null)
|
||||||
|
{
|
||||||
|
return BadRequest(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
// These properties are server-managed or optional graph members and
|
// These properties are server-managed or optional graph members and
|
||||||
// should not block JSON payloads coming from API clients.
|
// should not block JSON payloads coming from API clients.
|
||||||
ModelState.Remove(nameof(Models.Blog.BlogPost.Author));
|
ModelState.Remove(nameof(Models.Blog.BlogPost.Author));
|
||||||
|
|
@ -73,6 +81,10 @@ namespace Yavsc.Blogs.Controllers
|
||||||
return BadRequest();
|
return BadRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var files = Request.HasFormContentType
|
||||||
|
? Request.Form.Files
|
||||||
|
: (IFormFileCollection)new FormFileCollection();
|
||||||
|
|
||||||
var existing = await blogSpotService.GetBlogPostAsync(id);
|
var existing = await blogSpotService.GetBlogPostAsync(id);
|
||||||
if (existing == null)
|
if (existing == null)
|
||||||
{
|
{
|
||||||
|
|
@ -81,7 +93,7 @@ namespace Yavsc.Blogs.Controllers
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await blogSpotService.Modify(User, blog);
|
await blogSpotService.Modify(User, blog, files);
|
||||||
}
|
}
|
||||||
catch (AuthorizationFailureException)
|
catch (AuthorizationFailureException)
|
||||||
{
|
{
|
||||||
|
|
@ -197,6 +209,34 @@ namespace Yavsc.Blogs.Controllers
|
||||||
{
|
{
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<Models.Blog.BlogPost?> ReadPutBlogRequestAsync()
|
||||||
|
{
|
||||||
|
if (!Request.HasFormContentType)
|
||||||
|
{
|
||||||
|
return await Request.ReadFromJsonAsync<Models.Blog.BlogPost>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = Request.Form["blog"].ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError("blog", "A blog payload is required in the multipart form field 'blog'.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<Models.Blog.BlogPost>(raw, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError("blog", $"Invalid blog JSON payload: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
using Yavsc.Abstract.Files;
|
||||||
|
|
||||||
|
namespace Yavsc.Org.Tests.NonRegression;
|
||||||
|
|
||||||
|
public class FileServerUrlHelpersTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetUserFilesBaseUri_appends_the_user_files_path_to_the_authority_root()
|
||||||
|
{
|
||||||
|
var baseUri = FileServerUrlHelpers.GetUserFilesBaseUri("https://oidc.example.org");
|
||||||
|
|
||||||
|
Assert.Equal("https://oidc.example.org/files/", baseUri.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetUserFilesBaseUri_preserves_the_authority_and_discards_any_existing_path()
|
||||||
|
{
|
||||||
|
var baseUri = FileServerUrlHelpers.GetUserFilesBaseUri("https://oidc.example.org/signin");
|
||||||
|
|
||||||
|
Assert.Equal("https://oidc.example.org/files/", baseUri.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetUserFilesUri_builds_an_absolute_file_url_from_a_relative_path()
|
||||||
|
{
|
||||||
|
var fileUri = FileServerUrlHelpers.GetUserFilesUri(
|
||||||
|
"https://oidc.example.org",
|
||||||
|
"/alice/inbox/report.pdf");
|
||||||
|
|
||||||
|
Assert.Equal("https://oidc.example.org/files/alice/inbox/report.pdf", fileUri.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetUserFilesUri_rejects_blank_relative_path()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(
|
||||||
|
() => FileServerUrlHelpers.GetUserFilesUri(
|
||||||
|
"https://oidc.example.org",
|
||||||
|
" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -186,6 +186,59 @@ public class BlogSpotService
|
||||||
_context.SaveChanges(user.GetUserId());
|
_context.SaveChanges(user.GetUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task Modify(ClaimsPrincipal user, BlogPost blog, IFormFileCollection files)
|
||||||
|
{
|
||||||
|
await Modify(user, blog);
|
||||||
|
|
||||||
|
if (files == null || files.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var userId = user.GetUserId();
|
||||||
|
var userEntity = _context.Users.FirstOrDefault(u => u.Id == userId);
|
||||||
|
if (userEntity == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string blogFilesSubdir = $"blogs/{blog.Id}";
|
||||||
|
string destDir = Path.Combine(
|
||||||
|
AbstractFileSystemHelpers.UserFilesDirName,
|
||||||
|
userEntity.UserName,
|
||||||
|
blogFilesSubdir);
|
||||||
|
var di = new DirectoryInfo(destDir);
|
||||||
|
if (!di.Exists) di.Create();
|
||||||
|
|
||||||
|
foreach (var formFile in files)
|
||||||
|
{
|
||||||
|
var fileInfo = userEntity.ReceiveUserFile(destDir, formFile);
|
||||||
|
if (fileInfo != null && !fileInfo.QuotaOffense)
|
||||||
|
{
|
||||||
|
var uploadedFile = new UploadedFile
|
||||||
|
{
|
||||||
|
Path = fileInfo.FileName,
|
||||||
|
ContentType = formFile.ContentType,
|
||||||
|
Length = formFile.Length
|
||||||
|
};
|
||||||
|
_context.UploadedFiles.Add(uploadedFile);
|
||||||
|
_context.SaveChanges(userId);
|
||||||
|
|
||||||
|
var attachment = new BlogAttachedFile
|
||||||
|
{
|
||||||
|
PostId = blog.Id,
|
||||||
|
FileId = uploadedFile.Id
|
||||||
|
};
|
||||||
|
_context.BlogAttachedFiles.Add(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.SaveChanges(userId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<IBlogPost>> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25)
|
public async Task<IEnumerable<IBlogPost>> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25)
|
||||||
{
|
{
|
||||||
IEnumerable<IBlogPost> posts;
|
IEnumerable<IBlogPost> posts;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue