using System; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; using Microsoft.Extensions.DependencyInjection; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; using Yavsc.Abstract.Identity.Security; using Yavsc.Api.Client; using Yavsc.Api.Client.Dtos; using Yavsc.Blogspot; namespace PostIt.Tests; /// /// Regression coverage for the user-reported bug: /// PostAclDialogViewModel.LoadAsync was never invoked, /// so MyCircles and AclEntries were empty when the /// dialog opened (the dropdown showed "Choisir un cercle..." and /// the list was blank, with no error to hint at why). /// /// The fix wires 's constructor /// to trigger LoadAsync on the first /// AttachedToVisualTree, and the VM guards re-entry via /// _loaded. Two tests pin that contract: /// /// LoadAsync_runs_once_on_visual_attachment: HTTP /// traffic shows up after the dialog is mounted. /// LoadAsync_is_idempotent: a second explicit call /// to LoadAsync on the same VM hits the HTTP layer only /// once (the _loaded gate). /// /// /// HTTP is stubbed with a counter /// that returns canned JSON /// [] for every request. The handler counts calls so the /// tests can assert "exactly one round-trip on mount" and /// "exactly one round-trip after two calls to LoadAsync". This /// is the same shape used by BearerScopeTests: real /// subclass, real /// with an injected handler, real /// / /// talking to it. /// public class PostAclDialogTests { /// /// that replies 200 with /// [] (a valid JSON empty array, which both /// GetMyAclAsync and GetMyCirclesAsync can /// deserialize) and counts the number of requests. /// private sealed class CountingHttpHandler : HttpMessageHandler { public int RequestCount { get; private set; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { RequestCount++; var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("[]", Encoding.UTF8, "application/json"), }; return Task.FromResult(response); } } /// /// Subclass of that routes HTTP /// traffic through a caller-supplied /// . Same recipe as /// BearerScopeTests.TestableYavscApiClient — we /// override CallAsync{T} to talk to our own /// and skip the OIDC refresh path, /// because the load-on-attach bug has nothing to do with /// token refresh. /// private sealed class TestableYavscApiClient : YavscApiClient { private readonly HttpClient _http; public TestableYavscApiClient( Settings settings, TokenStore store, HttpMessageHandler handler) : base(settings, store, oidc: null!) { _http = new HttpClient(handler, disposeHandler: false); } public override Task CallAsync( HttpMethod method, string path, object? body = null, CancellationToken ct = default) { var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path); using var req = new HttpRequestMessage(method, absolute); using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult(); resp.EnsureSuccessStatusCode(); using var stream = resp.Content.ReadAsStream(); var dto = JsonSerializer.Deserialize(stream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); return Task.FromResult(dto!); } } /// /// Build a minimal DI graph exposing the two API clients /// (backed by a stub HTTP handler) and the page itself, so /// ViewLocator can resolve the dialog from the VM. /// Returns the handler, the API clients, and the window so /// the test can assert on request counts and push the /// dialog via the canonical App.PushPageAsync path. /// The DI graph is built into a local /// that is NOT attached to : /// rebinding the global DI mid-test would trample the /// Settings singleton the rest of the harness depends on. /// private static (MainWindow window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount() { var handler = new CountingHttpHandler(); var settings = new Settings(); var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler); var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl); var circleClient = new CircleApiClient(api, settings.BusinessApiUrl); var services = new ServiceCollection(); services.AddSingleton(settings); services.AddSingleton(api); services.AddSingleton(aclClient); services.AddSingleton(circleClient); services.AddTransient(); var sp = services.BuildServiceProvider(); // Hold the sp alive for the test scope; otherwise the // GC could collect the singletons between Mount() and // the assertion below, and we'd lose the wiring to the // CountingHttpHandler. GC.KeepAlive(sp); var window = new MainWindow(); var app = (App)Application.Current!; app.DataTemplates.Clear(); app.DataTemplates.Add(new ViewLocator(sp)); app.AttachMainWindow(window); window.Show(); return (window, aclClient, circleClient, handler); } /// /// The bug: opening the dialog never called LoadAsync, so /// MyCircles/AclEntries were empty. After the fix, setting /// the dialog's DataContext to a PostAclDialogViewModel /// (the same path App.PushPageAsync takes) must trigger /// exactly one LoadAsync round-trip (the parallel WhenAll /// inside the VM counts as one request per backend call, /// hence two HTTP requests total: GET /blogacl and GET /// /circle). /// [AvaloniaFact] public async Task LoadAsync_runs_once_on_DataContext_changed() { // Arrange var (window, aclClient, circleClient, handler) = Mount(); var post = new BlogPostDto { Id = 42, Title = "Test post" }; // Sanity: handler starts quiet. Assert.Equal(0, handler.RequestCount); // Act: push the dialog via the canonical VM-first pipeline. // The locator goes through the parameterless ctor of // PostAclDialog, then App.PushPageAsync assigns DataContext, // which our hook intercepts to trigger LoadAsync. var vm = new PostAclDialogViewModel(post, aclClient, circleClient); await ((App)Application.Current!).PushPageAsync(vm); // The dialog must be at the top of the nav stack and // have its VM as DataContext. var dialog = window.NavRoot.NavigationStack[^1] as PostAclDialog ?? throw new InvalidOperationException("Dialog not at top of stack"); Assert.Same(vm, dialog.DataContext); // Drain pending async work. LoadAsync is async and the // DataContextChanged handler is fire-and-forget; a // couple of loop turns is enough. We poll the handler // counter because the dispatch back onto the headless // dispatcher isn't strict — using a generous-but-bounded // wait avoids test flakes. var deadline = DateTime.UtcNow.AddSeconds(2); while (handler.RequestCount < 2 && DateTime.UtcNow < deadline) { await Task.Delay(20); } // Assert: exactly two GETs went out (one to /blogacl, // one to /circle), both from the LoadAsync call. Assert.Equal(2, handler.RequestCount); // And the VM's idempotency gate has flipped. Assert.True(vm.Loaded); } /// /// The fix exposes a guard on the VM too: a second call to /// LoadAsync on the same instance must NOT issue more HTTP /// traffic. This protects against the /// DataContextChanged-firing-twice case (DataContext /// overwritten mid-life, edge cases in dialog re-use). /// [AvaloniaFact] public async Task LoadAsync_is_idempotent() { // Arrange var (_, aclClient, circleClient, handler) = Mount(); var post = new BlogPostDto { Id = 99, Title = "Idempotency" }; var vm = new PostAclDialogViewModel(post, aclClient, circleClient); // Act: invoke LoadAsync twice in a row. await vm.LoadAsync(); await vm.LoadAsync(); // Assert: the second call short-circuited on _loaded. Assert.Equal(2, handler.RequestCount); Assert.True(vm.Loaded); } }