feat(PostIt): unify moderator administration and restore Android navigation

Group users and usage in the moderator administration page; retain personal profile management and show personal usage. Allow moderators to read the corresponding API lists while preserving administrator-only mutations. Restore navigation pages after Android activity recreation. Update tests, architecture documentation and CHANGELOG for 1.0.8-rc27.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-18 15:56:58 +01:00
commit ba384ba4c0
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
21 changed files with 682 additions and 279 deletions

View file

@ -0,0 +1,60 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt.Tests;
public class ActivityNavigationTests
{
[AvaloniaFact]
public async Task Activity_recreation_restores_fresh_pages_and_back_navigation()
{
var app = Assert.IsType<App>(Application.Current);
var previousView = app.View;
var original = new MainView();
var home = new HomePage();
var draft = new Settings { SelectedLanguage = "fr" };
var editor = new ContentPage { DataContext = draft };
await original.NavRoot.PushAsync(home, null);
await original.NavRoot.PushAsync(editor, null);
app.AttachMainWindow(original);
var window = new Window();
try
{
var restored = app.CreateActivityView(new Settings());
window.Content = restored;
window.Show();
Dispatcher.UIThread.RunJobs();
Assert.Equal(2, restored.NavRoot.NavigationStack.Count);
Assert.IsType<HomePage>(restored.NavRoot.NavigationStack[0]);
Assert.NotSame(home, restored.NavRoot.NavigationStack[0]);
Assert.NotSame(editor, restored.NavRoot.NavigationStack[1]);
Assert.Same(draft, restored.NavRoot.NavigationStack[1].DataContext);
Assert.Equal("fr", draft.SelectedLanguage);
window.Content = null;
window.Content = restored;
Dispatcher.UIThread.RunJobs();
Assert.Equal(2, restored.NavRoot.NavigationStack.Count);
// Another activity recreation must retain the already-restored stack.
window.Content = null;
var recreatedAgain = app.CreateActivityView(new Settings());
window.Content = recreatedAgain;
Dispatcher.UIThread.RunJobs();
Assert.Equal(2, recreatedAgain.NavRoot.NavigationStack.Count);
Assert.Same(draft, recreatedAgain.NavRoot.NavigationStack[1].DataContext);
await recreatedAgain.NavRoot.PopAsync(null);
Assert.IsType<HomePage>(Assert.Single(recreatedAgain.NavRoot.NavigationStack));
}
finally
{
window.Close();
app.AttachMainWindow(previousView ?? new MainView());
}
}
}

View file

@ -0,0 +1,241 @@
using System.Net.Http;
using System.Text.Json;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class AdministrationAccessTests
{
[AvaloniaTheory]
[InlineData("Moderator", true)]
[InlineData("Administrator", false)]
[InlineData("Performer", false)]
[InlineData("moderator", false)]
[InlineData("", false)]
public async Task Home_administration_visibility_and_command_follow_the_moderator_role(string role, bool allowed)
{
await using var api = new ProfileTransport(role);
var session = new SessionStatusViewModel { Api = api };
var vm = new HomePageViewModel(api, api.Settings, session, null!);
var page = new HomePage { DataContext = vm };
var window = new Window { Content = page };
try
{
window.Show();
var button = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
button => Equals(button.Content, "Administration"));
Assert.False(button.IsVisible);
await session.RefreshAsync();
Assert.Equal(allowed, session.IsModerator);
Assert.Equal(allowed, button.IsVisible);
Assert.Equal(allowed, vm.OpenAdministration.CanExecute(null));
Assert.DoesNotContain(page.GetVisualDescendants().OfType<Button>(),
button => Equals(button.Content, "Mon usage / administration"));
session.Api = null;
session.Refresh();
Assert.False(button.IsVisible);
Assert.False(vm.OpenAdministration.CanExecute(null));
}
finally
{
window.Close();
}
}
[Fact]
public async Task Late_role_response_cannot_restore_access_after_logout()
{
await using var api = new ProfileTransport("Moderator");
var pending = new TaskCompletionSource<Me>();
api.PendingProfile = pending.Task;
var session = new SessionStatusViewModel { Api = api };
var refresh = session.RefreshAsync();
session.Api = null;
session.Refresh();
pending.SetResult(new Me { Roles = ["Moderator"] });
await refresh;
Assert.False(session.IsModerator);
}
[Fact]
public async Task Role_lookup_failure_denies_access_and_reports_the_error()
{
await using var api = new ProfileTransport("Moderator") { FailProfile = true };
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
Assert.False(session.IsModerator);
Assert.Contains("Impossible de vérifier les droits", session.SessionLabel);
}
[Fact]
public async Task Administration_loads_users_and_usage_and_matches_by_id()
{
await using var api = new ProfileTransport("Moderator");
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
await vm.RefreshAsync();
vm.SelectedUser = Assert.Single(vm.Users);
Assert.Equal("alice", vm.SelectedUsage?.UserName);
Assert.Equal(12m, vm.SelectedUsage?.ApiCalls);
Assert.Single(vm.UsageOverview);
Assert.Contains(api.Requests, path => path.EndsWith("/resource-usage/admin"));
Assert.DoesNotContain(api.Requests, path => path.EndsWith("/resource-usage/me"));
}
[AvaloniaFact]
public async Task Administration_displays_both_tabs_and_selected_user_usage()
{
await using var api = new ProfileTransport("Moderator");
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
await vm.RefreshAsync();
vm.SelectedUser = Assert.Single(vm.Users);
var page = new AdministrationPage { DataContext = vm };
var window = new Window { Content = page, Width = 800, Height = 600 };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var tabs = Assert.Single(page.GetVisualDescendants().OfType<TabControl>());
Assert.Equal(2, tabs.Items.Count);
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(),
text => text.Text == "Appels API : 12");
tabs.SelectedIndex = 1;
Dispatcher.UIThread.RunJobs();
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(),
text => text.Text == "alice-id");
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(),
text => text.Text == "Appels API : 12");
}
finally
{
window.Close();
}
}
[Fact]
public async Task Administration_denies_non_moderators_without_requesting_private_data()
{
await using var api = new ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
api.Requests.Clear();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
await vm.RefreshAsync();
Assert.Empty(api.Requests);
Assert.Empty(vm.Users);
Assert.Contains("Moderator", vm.StatusMessage);
}
[Fact]
public async Task Administration_failure_reports_error_without_falling_back_to_personal_usage()
{
await using var api = new ProfileTransport("Moderator") { FailUsage = true };
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
await vm.RefreshAsync();
Assert.Empty(vm.Users);
Assert.Empty(vm.UsageOverview);
Assert.Contains("indisponible", vm.StatusMessage);
Assert.DoesNotContain(api.Requests, path => path.EndsWith("/resource-usage/me"));
}
[Fact]
public async Task My_profile_loads_own_profile_enables_saving_and_only_loads_personal_usage()
{
await using var api = new ProfileTransport("Performer");
var vm = new UserProfilePageViewModel(api, usageClient: api.UsageClient);
await vm.RefreshAsync();
Assert.True(vm.IsProfileLoaded);
Assert.Equal(12m, vm.ResourceUsage?.ApiCalls);
Assert.True(vm.SaveProfileCommand.CanExecute(null));
vm.FullName = "Alice Updated";
Assert.Equal("Alice Updated", vm.BuildProfileUpdateRequest().FullName);
Assert.DoesNotContain(api.Requests, path => path.EndsWith("/resource-usage/admin"));
}
[Fact]
public async Task Usage_failure_does_not_disable_profile_management()
{
await using var api = new ProfileTransport("Performer") { FailUsage = true };
var vm = new UserProfilePageViewModel(api, usageClient: api.UsageClient);
await vm.RefreshAsync();
Assert.True(vm.IsProfileLoaded);
Assert.True(vm.SaveProfileCommand.CanExecute(null));
Assert.Null(vm.ResourceUsage);
Assert.Contains("Impossible de charger mon usage", vm.UsageError);
}
private sealed class ProfileTransport : YavscApiClient
{
private readonly string _tokenPath;
private readonly string _role;
public List<string> Requests { get; } = [];
public Task<Me>? PendingProfile { get; set; }
public bool FailProfile { get; set; }
public bool FailUsage { get; set; }
public ResourceUsageApiClient UsageClient => new(this, Settings.ApiUrl);
public ProfileTransport(string role)
: this(role, Path.Combine(Path.GetTempPath(), $"postit-admin-{Guid.NewGuid():N}.json")) { }
private ProfileTransport(string role, string path) : base(new Settings(), CreateStore(path))
{
_role = role;
_tokenPath = path;
}
private static TokenStore CreateStore(string path)
{
var store = new TokenStore(path);
store.Save(new RefreshTokenRecord("access", "refresh", DateTimeOffset.UtcNow.AddHours(1), null));
return store;
}
public override async Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
Requests.Add(path);
object result;
if (path.EndsWith("/account/me"))
{
if (FailProfile)
throw new HttpRequestException("Profile unavailable");
result = PendingProfile is not null ? await PendingProfile
: new Me { UserId = "alice-id", UserName = "alice", Roles = [_role] };
}
else if (path.Contains("/resource-usage/"))
{
if (FailUsage)
throw new HttpRequestException("Usage unavailable");
var usage = new ResourceUsageSummary { UserId = "alice-id", UserName = "alice", ApiCalls = 12m };
result = path.EndsWith("/admin") ? new List<ResourceUsageSummary> { usage } : usage;
}
else if (path.EndsWith("/users"))
result = new List<UserInfo> { new() { UserId = "alice-id", UserName = "alice" } };
else
throw new InvalidOperationException($"Unexpected endpoint: {path}");
return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(result))!;
}
public new async ValueTask DisposeAsync()
{
await base.DisposeAsync();
File.Delete(_tokenPath);
}
}
}

View file

@ -38,7 +38,7 @@ public class HomePageProviderFlowTests
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenAdministration);
Assert.True(vm.OpenAdministration.CanExecute(null));
Assert.False(vm.OpenAdministration.CanExecute(null));
}
[Fact]