Compare commits
2 commits
a44c04ad77
...
88461786ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
88461786ee |
|||
|
06672c4c90 |
20 changed files with 5151 additions and 539 deletions
|
|
@ -1,4 +1,6 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Headless.XUnit;
|
using Avalonia.Headless.XUnit;
|
||||||
|
|
@ -29,15 +31,8 @@ namespace PostIt.Tests;
|
||||||
/// click via <c>button.Command?.Execute(...)</c> + flush
|
/// click via <c>button.Command?.Execute(...)</c> + flush
|
||||||
/// any async command before asserting.</para>
|
/// any async command before asserting.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("PostIt Headless")]
|
|
||||||
public class AddCircleMemberDialogTests
|
public class AddCircleMemberDialogTests
|
||||||
{
|
{
|
||||||
private PostItHeadlessCollection fixture;
|
|
||||||
|
|
||||||
public AddCircleMemberDialogTests(PostItHeadlessCollection fixture, ITestOutputHelper output)
|
|
||||||
{
|
|
||||||
this.fixture = fixture;
|
|
||||||
}
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stand-in <see cref="IUserDirectory"/> that returns an
|
/// Stand-in <see cref="IUserDirectory"/> that returns an
|
||||||
/// empty list. The dialog's "Rechercher" button is never
|
/// empty list. The dialog's "Rechercher" button is never
|
||||||
|
|
@ -78,10 +73,7 @@ public class AddCircleMemberDialogTests
|
||||||
/// VM resolves its dependency) and <c>AddCircleMemberDialog</c>
|
/// VM resolves its dependency) and <c>AddCircleMemberDialog</c>
|
||||||
/// (so <c>ViewLocator</c> can resolve it from the VM).
|
/// (so <c>ViewLocator</c> can resolve it from the VM).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static (MainWindow window,
|
private static (MainWindow window, CirclesPage page, AddCircleMemberDialog dialog) Mount()
|
||||||
CirclesPage page,
|
|
||||||
AddCircleMemberDialog dialog)
|
|
||||||
Mount()
|
|
||||||
{
|
{
|
||||||
var api = new ThrowingApi();
|
var api = new ThrowingApi();
|
||||||
var circleClient = new CircleApiClient(api, "http://localhost/");
|
var circleClient = new CircleApiClient(api, "http://localhost/");
|
||||||
|
|
@ -125,7 +117,7 @@ public class AddCircleMemberDialogTests
|
||||||
public void Close_button_pops_dialog_off_nav_stack()
|
public void Close_button_pops_dialog_off_nav_stack()
|
||||||
{
|
{
|
||||||
// Arrange: stack starts at 2 (CirclesPage + dialog).
|
// Arrange: stack starts at 2 (CirclesPage + dialog).
|
||||||
var window = fixture.Window;
|
var (window, _, _) = Mount();
|
||||||
var stackBefore = window.NavRoot.NavigationStack.Count;
|
var stackBefore = window.NavRoot.NavigationStack.Count;
|
||||||
Assert.Equal(2, stackBefore);
|
Assert.Equal(2, stackBefore);
|
||||||
|
|
||||||
|
|
@ -64,29 +64,3 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
|
||||||
return Task.FromResult(default(T)!);
|
return Task.FromResult(default(T)!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <see cref="YavscApiClient"/> stand-in whose constructor
|
|
||||||
/// points at <c>https://stub.invalid</c> so any HTTP traffic
|
|
||||||
/// that escapes a test (misconfigured command, missing fake
|
|
||||||
/// handler) raises a clear <see cref="System.Net.Http.HttpRequestException"/>
|
|
||||||
/// instead of silently hitting a real endpoint. Used by tests
|
|
||||||
/// that don't actually exercise the API client (they click a
|
|
||||||
/// button, assert on the nav stack, end of story) but whose
|
|
||||||
/// VMs require one in their constructor.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class ThrowingApi : YavscApiClient
|
|
||||||
{
|
|
||||||
public ThrowingApi() : base(
|
|
||||||
new Settings
|
|
||||||
{
|
|
||||||
Authentication = new AuthenticationSettings
|
|
||||||
{
|
|
||||||
Authority = "https://stub.invalid",
|
|
||||||
ClientId = "stub",
|
|
||||||
Scopes = new[] { "openid" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
new TokenStore(System.IO.Path.GetTempFileName()))
|
|
||||||
{ }
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
|
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Avalonia.Headless.XUnit;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using PostIt.Services;
|
|
||||||
using PostIt.ViewModels;
|
|
||||||
using PostIt.Views;
|
|
||||||
using Yavsc.Api.Client;
|
|
||||||
using Yavsc.Blogspot;
|
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Regression coverage for the user-reported bug:
|
|
||||||
/// <c>PostAclDialogViewModel.LoadAsync</c> was never invoked,
|
|
||||||
/// so <c>MyCircles</c> and <c>AclEntries</c> were empty when the
|
|
||||||
/// dialog opened (the dropdown showed "Choisir un cercle..." and
|
|
||||||
/// the list was blank, with no error to hint at why).
|
|
||||||
///
|
|
||||||
/// <para>The fix wires <see cref="PostAclDialog"/>'s constructor
|
|
||||||
/// to trigger <c>LoadAsync</c> on the first
|
|
||||||
/// <c>DataContextChanged</c>, and the VM guards re-entry via
|
|
||||||
/// <c>_loaded</c>. Two tests pin that contract:</para>
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item><c>LoadAsync_runs_once_on_DataContext_changed</c>: HTTP
|
|
||||||
/// traffic shows up after the dialog is mounted.</item>
|
|
||||||
/// <item><c>LoadAsync_is_idempotent</c>: a second explicit call
|
|
||||||
/// to <c>LoadAsync</c> on the same VM hits the HTTP layer only
|
|
||||||
/// once (the <c>_loaded</c> gate).</item>
|
|
||||||
/// </list>
|
|
||||||
///
|
|
||||||
/// <para>HTTP is stubbed with a counter
|
|
||||||
/// <see cref="HttpMessageHandler"/> that returns canned JSON
|
|
||||||
/// <c>[]</c> 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 <c>BearerScopeTests</c>: real
|
|
||||||
/// <see cref="YavscApiClient"/> subclass, real
|
|
||||||
/// <see cref="HttpClient"/> with an injected handler, real
|
|
||||||
/// <see cref="BlogAclApiClient"/> / <see cref="CircleApiClient"/>
|
|
||||||
/// talking to it.</para>
|
|
||||||
///
|
|
||||||
/// <para>Lifecycle: shared <see cref="PostItHeadlessFixture"/>
|
|
||||||
/// provides the <see cref="MainWindow"/> already wired to
|
|
||||||
/// <see cref="App"/>. Each test builds its own DI graph with
|
|
||||||
/// the counting HTTP handler and swaps it in via
|
|
||||||
/// <see cref="PostItHeadlessFixture.UseServiceProvider"/>. The
|
|
||||||
/// graph exposes <c>PostAclDialog</c> so the
|
|
||||||
/// <see cref="ViewLocator"/> resolves it from
|
|
||||||
/// <see cref="PostAclDialogViewModel"/>.</para>
|
|
||||||
/// </summary>
|
|
||||||
[Collection("PostIt Headless")]
|
|
||||||
public sealed class PostAclDialogTests
|
|
||||||
{
|
|
||||||
private readonly PostItHeadlessCollection _host;
|
|
||||||
|
|
||||||
public PostAclDialogTests(PostItHeadlessCollection host)
|
|
||||||
{
|
|
||||||
_host = host;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <see cref="HttpMessageHandler"/> that replies 200 with
|
|
||||||
/// <c>[]</c> (a valid JSON empty array, which both
|
|
||||||
/// <c>GetMyAclAsync</c> and <c>GetMyCirclesAsync</c> can
|
|
||||||
/// deserialize) and counts the number of requests.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class CountingHttpHandler : HttpMessageHandler
|
|
||||||
{
|
|
||||||
public int RequestCount { get; private set; }
|
|
||||||
|
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
RequestCount++;
|
|
||||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
|
||||||
{
|
|
||||||
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
|
|
||||||
};
|
|
||||||
return Task.FromResult(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
|
|
||||||
/// traffic through a caller-supplied
|
|
||||||
/// <see cref="HttpMessageHandler"/>. Same recipe as
|
|
||||||
/// <c>BearerScopeTests.TestableYavscApiClient</c> — we
|
|
||||||
/// override <c>CallAsync{T}</c> to talk to our own
|
|
||||||
/// <see cref="HttpClient"/> and skip the OIDC refresh path,
|
|
||||||
/// because the load-on-attach bug has nothing to do with
|
|
||||||
/// token refresh.
|
|
||||||
/// </summary>
|
|
||||||
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<T> CallAsync<T>(
|
|
||||||
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<T>(stream,
|
|
||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
||||||
return Task.FromResult(dto!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Headless;
|
||||||
using Avalonia.Headless.XUnit;
|
using Avalonia.Headless.XUnit;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
|
@ -48,43 +50,43 @@ namespace PostIt.Tests;
|
||||||
/// <item>"[DEV] Signature" — click pushes a page onto the
|
/// <item>"[DEV] Signature" — click pushes a page onto the
|
||||||
/// stack.</item>
|
/// stack.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
///
|
|
||||||
/// <para>Lifecycle: shared <see cref="PostItHeadlessFixture"/>
|
|
||||||
/// owns the <see cref="MainWindow"/> and the production DI graph.
|
|
||||||
/// Each test builds a local <see cref="ServiceCollection"/> with
|
|
||||||
/// the fake <see cref="YavscApiClient"/> + the page VMs and
|
|
||||||
/// registers the destination pages, then swaps it in via
|
|
||||||
/// <see cref="PostItHeadlessFixture.UseServiceProvider"/>. The
|
|
||||||
/// fixture re-attaches the ViewLocator and the MainWindow so
|
|
||||||
/// subsequent <see cref="App.PushPageAsync"/> calls route through
|
|
||||||
/// the overridden graph.</para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("PostIt Headless")]
|
public class MainPageButtonsTests
|
||||||
public sealed class MainPageButtonsTests
|
|
||||||
{
|
{
|
||||||
private readonly PostItHeadlessCollection _host;
|
/// <summary>
|
||||||
|
/// Fake <see cref="YavscApiClient"/> that throws on any
|
||||||
public MainPageButtonsTests(PostItHeadlessCollection host)
|
/// wire call. These tests never invoke a command that hits
|
||||||
|
/// the API — only the click → nav side of the pipeline is
|
||||||
|
/// asserted.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ThrowingApi : YavscApiClient
|
||||||
{
|
{
|
||||||
_host = host;
|
public ThrowingApi() : base(
|
||||||
|
new Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "https://stub.invalid",
|
||||||
|
ClientId = "stub",
|
||||||
|
Scopes = new[] { "openid" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new TokenStore(System.IO.Path.GetTempFileName()))
|
||||||
|
{ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null)
|
||||||
/// Build the test DI graph: <see cref="ThrowingApi"/> for
|
|
||||||
/// the API clients (the click tests never hit the wire;
|
|
||||||
/// any traffic would be a wiring bug), the real
|
|
||||||
/// <see cref="BlogApiClient"/> / <see cref="CircleApiClient"/>
|
|
||||||
/// / <see cref="BlogAclApiClient"/> that the page VM
|
|
||||||
/// resolves, and the page + dialog + VM registrations the
|
|
||||||
/// <see cref="ViewLocator"/> needs to resolve the three
|
|
||||||
/// push targets.
|
|
||||||
/// </summary>
|
|
||||||
private MainPageViewModel BuildViewModel(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
|
||||||
|
// when the user clicks a navigation button. Today that's
|
||||||
|
// SignaturePageViewModel / CirclesPageViewModel / ACL
|
||||||
|
// dependencies. The graph intentionally stays local to this
|
||||||
|
// suite to avoid side effects from App.BuildServices() (real
|
||||||
|
// token-store wiring).
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddSingleton(new Settings());
|
services.AddSingleton(new Settings());
|
||||||
services.AddSingleton(circle);
|
services.AddSingleton(circle);
|
||||||
|
|
@ -94,31 +96,51 @@ public sealed class MainPageButtonsTests
|
||||||
services.AddTransient<SignaturePage>();
|
services.AddTransient<SignaturePage>();
|
||||||
services.AddTransient<CirclesPage>();
|
services.AddTransient<CirclesPage>();
|
||||||
services.AddTransient<PostAclDialog>();
|
services.AddTransient<PostAclDialog>();
|
||||||
var sp = services.BuildServiceProvider();
|
var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider());
|
||||||
|
|
||||||
var vm = new MainPageViewModel(blog, services: sp);
|
|
||||||
if (selectedPost is not null) vm.SelectedPost = selectedPost;
|
if (selectedPost is not null) vm.SelectedPost = selectedPost;
|
||||||
return vm;
|
return vm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Push a <see cref="MainPage"/> with the given VM onto
|
/// Mount a real <see cref="MainWindow"/> (as
|
||||||
/// the shared <see cref="MainWindow"/>'s nav stack. Clears
|
/// <c>SessionStatusBannerTests</c> does), push a
|
||||||
/// any pages the previous test left behind (the fixture's
|
/// <see cref="MainPage"/> with the given VM onto
|
||||||
/// MainWindow is shared across every test class). Returns
|
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
|
||||||
/// the live page so the test can access its named buttons.
|
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
|
||||||
|
/// nav stack before the test tries to interact with its
|
||||||
|
/// named buttons. The window is shown so the visual tree is
|
||||||
|
/// realised and <c>KeyPressQwerty</c> has a real
|
||||||
|
/// <see cref="TopLevel"/> to dispatch against.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private MainPage MountAsync(MainPageViewModel vm)
|
private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm)
|
||||||
{
|
{
|
||||||
|
var window = new MainWindow();
|
||||||
var page = new MainPage { DataContext = vm };
|
var page = new MainPage { DataContext = vm };
|
||||||
_host.PushAsync(page);
|
var app = (PostIt.App)Application.Current!;
|
||||||
return page;
|
if (vm.Services is not null)
|
||||||
|
{
|
||||||
|
app.DataTemplates.Clear();
|
||||||
|
app.DataTemplates.Add(new ViewLocator(vm.Services));
|
||||||
|
}
|
||||||
|
app.AttachMainWindow(window);
|
||||||
|
window.Show();
|
||||||
|
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
|
||||||
|
return (window, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Click a button by executing its <see cref="Button.Command"/>
|
/// Click a button by focusing it and pressing Enter — the
|
||||||
/// and draining any <see cref="IAsyncRelayCommand"/> so the
|
/// supported headless pattern (cf. CalculatorTests in the
|
||||||
/// caller can assert on the resulting nav stack immediately.
|
/// Avalonia.Samples repo). Returns the nav-stack count
|
||||||
|
/// before the click so the caller can assert on the delta.
|
||||||
|
/// KeyPressQwerty is dispatched on the <see cref="MainWindow"/>
|
||||||
|
/// itself — it is the <see cref="TopLevel"/> that owns the
|
||||||
|
/// headless implementation, and routing the key through any
|
||||||
|
/// descendant TopLevel (e.g. one obtained via
|
||||||
|
/// <c>TopLevel.GetTopLevel(button)</c>) fails with a
|
||||||
|
/// <c>NullReferenceException</c> from the headless impl
|
||||||
|
/// because the descendant does not carry the
|
||||||
|
/// <c>PlatformHandle</c> the harness expects.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static int ClickAndCapture(MainWindow window, Button button)
|
private static int ClickAndCapture(MainWindow window, Button button)
|
||||||
{
|
{
|
||||||
|
|
@ -143,8 +165,8 @@ public sealed class MainPageButtonsTests
|
||||||
Title = "An existing post",
|
Title = "An existing post",
|
||||||
AuthorId = "u-alice"
|
AuthorId = "u-alice"
|
||||||
};
|
};
|
||||||
var vm = BuildViewModel(post);
|
var vm = MakeViewModel(post);
|
||||||
var page = MountAsync(vm);
|
var (window, page) = MountMainPage(vm);
|
||||||
|
|
||||||
// Sanity: the button's command is bound and CanExecute
|
// Sanity: the button's command is bound and CanExecute
|
||||||
// is true. If this fails, the bug is upstream (XAML
|
// is true. If this fails, the bug is upstream (XAML
|
||||||
|
|
@ -154,12 +176,12 @@ public sealed class MainPageButtonsTests
|
||||||
Assert.True(aclButton.Command.CanExecute(null));
|
Assert.True(aclButton.Command.CanExecute(null));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var stackBefore = ClickAndCapture(_host.Window, aclButton);
|
var stackBefore = ClickAndCapture(window, aclButton);
|
||||||
|
|
||||||
// Assert γ + sniff léger: stack grew, new top is a Page.
|
// Assert γ + sniff léger: stack grew, new top is a Page.
|
||||||
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
|
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
|
||||||
$"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {_host.Window.NavRoot.NavigationStack.Count}.");
|
$"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
|
||||||
var pushed = _host.Window.NavRoot.NavigationStack[^1];
|
var pushed = window.NavRoot.NavigationStack.Last();
|
||||||
Assert.NotNull(pushed);
|
Assert.NotNull(pushed);
|
||||||
Assert.IsAssignableFrom<Page>(pushed);
|
Assert.IsAssignableFrom<Page>(pushed);
|
||||||
}
|
}
|
||||||
|
|
@ -169,19 +191,19 @@ public sealed class MainPageButtonsTests
|
||||||
{
|
{
|
||||||
// Arrange: OpenCircles has no CanExecute guard today —
|
// Arrange: OpenCircles has no CanExecute guard today —
|
||||||
// any click should fire it and push the page.
|
// any click should fire it and push the page.
|
||||||
var vm = BuildViewModel();
|
var vm = MakeViewModel();
|
||||||
var page = MountAsync(vm);
|
var (window, page) = MountMainPage(vm);
|
||||||
|
|
||||||
var circlesButton = page.OpenCirclesButton;
|
var circlesButton = page.OpenCirclesButton;
|
||||||
Assert.NotNull(circlesButton.Command);
|
Assert.NotNull(circlesButton.Command);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var stackBefore = ClickAndCapture(_host.Window, circlesButton);
|
var stackBefore = ClickAndCapture(window, circlesButton);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
|
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
|
||||||
"Click on 'Mes cercles' must push a new page onto the nav stack.");
|
"Click on 'Mes cercles' must push a new page onto the nav stack.");
|
||||||
var pushed = _host.Window.NavRoot.NavigationStack[^1];
|
var pushed = window.NavRoot.NavigationStack.Last();
|
||||||
Assert.NotNull(pushed);
|
Assert.NotNull(pushed);
|
||||||
Assert.IsAssignableFrom<Page>(pushed);
|
Assert.IsAssignableFrom<Page>(pushed);
|
||||||
}
|
}
|
||||||
|
|
@ -192,25 +214,25 @@ public sealed class MainPageButtonsTests
|
||||||
// Arrange: the "[DEV] Signature" button is bound to the
|
// Arrange: the "[DEV] Signature" button is bound to the
|
||||||
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
|
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
|
||||||
// The click must push SignaturePage on top of NavRoot.
|
// The click must push SignaturePage on top of NavRoot.
|
||||||
// The ServiceCollection registered in BuildViewModel
|
// The ServiceCollection registered in MakeViewModel provides
|
||||||
// provides SignaturePageViewModel so the command can
|
// SignaturePageViewModel so the command can resolve it via
|
||||||
// resolve it via DI and call App.PushPage; the
|
// DI and call App.PushPage; the ViewLocator
|
||||||
// ViewLocator then maps SignaturePageViewModel ->
|
// then maps SignaturePageViewModel -> SignaturePage and
|
||||||
// SignaturePage and the binding pushes the page.
|
// the binding pushes the page.
|
||||||
var vm = BuildViewModel();
|
var vm = MakeViewModel();
|
||||||
var page = MountAsync(vm);
|
var (window, page) = MountMainPage(vm);
|
||||||
|
|
||||||
var signatureButton = page.OpenSignatureDevButton;
|
var signatureButton = page.OpenSignatureDevButton;
|
||||||
Assert.NotNull(signatureButton.Command);
|
Assert.NotNull(signatureButton.Command);
|
||||||
Assert.True(signatureButton.Command.CanExecute(null));
|
Assert.True(signatureButton.Command.CanExecute(null));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var stackBefore = ClickAndCapture(_host.Window, signatureButton);
|
var stackBefore = ClickAndCapture(window, signatureButton);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
|
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
|
||||||
"Click on '[DEV] Signature' must push a new page onto the nav stack.");
|
"Click on '[DEV] Signature' must push a new page onto the nav stack.");
|
||||||
var pushed = _host.Window.NavRoot.NavigationStack[^1];
|
var pushed = window.NavRoot.NavigationStack.Last();
|
||||||
Assert.NotNull(pushed);
|
Assert.NotNull(pushed);
|
||||||
Assert.IsAssignableFrom<Page>(pushed);
|
Assert.IsAssignableFrom<Page>(pushed);
|
||||||
}
|
}
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Headless.XUnit;
|
using Avalonia.Headless.XUnit;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Yavsc.Blogspot;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
using PostIt.Views;
|
using PostIt.Views;
|
||||||
using Yavsc.Api.Client;
|
|
||||||
using Yavsc.Blogspot;
|
|
||||||
|
|
||||||
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="MainPage"/>.
|
||||||
/// Uses the shared <see cref="PostItHeadlessFixture"/> (a real
|
/// The pattern is the one <c>SessionStatusBannerTests</c>
|
||||||
/// <see cref="MainWindow"/> with the production DI graph attached
|
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
|
||||||
/// to <see cref="App"/>) plus a local
|
/// hosting the page (via a <see cref="Frame"/> because
|
||||||
/// <see cref="ServiceCollection"/> that swaps
|
/// <c>MainPage</c> is a <c>ContentPage</c>), then drive the
|
||||||
/// <see cref="YavscApiClient"/> for the recording fake.
|
/// controls through their public surface and assert on what
|
||||||
|
/// <see cref="RecordingYavscApiClient"/> saw go on the wire.
|
||||||
///
|
///
|
||||||
/// <para>The bug we are pinning: the title <c>TextBox</c> is
|
/// <para>The bug we are pinning: the title <c>TextBox</c> is
|
||||||
/// currently <c>{Binding SelectedPost.Title, Mode=TwoWay}</c>.
|
/// currently <c>{Binding SelectedPost.Title, Mode=TwoWay}</c>.
|
||||||
|
|
@ -31,38 +31,41 @@ namespace PostIt.Tests;
|
||||||
/// pass once the VM owns a dedicated <c>Title</c>/<c>Article</c>
|
/// pass once the VM owns a dedicated <c>Title</c>/<c>Article</c>
|
||||||
/// buffer that the XAML binds to and the Save command consumes.</para>
|
/// buffer that the XAML binds to and the Save command consumes.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("PostIt Headless")]
|
public class MainPageSaveTests
|
||||||
public sealed class MainPageSaveTests
|
|
||||||
{
|
{
|
||||||
private readonly PostItHeadlessCollection _host;
|
|
||||||
|
|
||||||
public MainPageSaveTests(PostItHeadlessCollection host)
|
|
||||||
{
|
|
||||||
_host = host;
|
|
||||||
}
|
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body()
|
public async Task Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body()
|
||||||
{
|
{
|
||||||
// Arrange: VM with a recording API client, mounted on
|
// Arrange: VM with a recording API client, mounted in a
|
||||||
// the shared MainWindow's nav stack.
|
// headless window via a Frame (MainPage is a ContentPage,
|
||||||
|
// not a Control, so it needs a navigation host).
|
||||||
var recorder = new CallRecorder();
|
var recorder = new CallRecorder();
|
||||||
|
var api = new RecordingYavscApiClient(recorder);
|
||||||
var blog = _host.Services.GetRequiredService<BlogApiClient>();
|
var blog = new BlogApiClient(api, "http://localhost/");
|
||||||
var viewModel = new MainPageViewModel(blog);
|
var viewModel = new MainPageViewModel(blog);
|
||||||
|
|
||||||
var page = new MainPage { DataContext = viewModel };
|
var page = new MainPage { DataContext = viewModel };
|
||||||
_host.PushAsync(page);
|
// MainPage is a ContentPage (a Page, not a Control), so it
|
||||||
|
// must be hosted in a navigation surface. The production
|
||||||
|
// MainWindow.axaml uses NavigationPage, and the API is the
|
||||||
|
// same one App.axaml.cs drives at boot (PushAsync, fire-
|
||||||
|
// and-forget in prod because the page is the top of the
|
||||||
|
// stack immediately).
|
||||||
|
var nav = new NavigationPage();
|
||||||
|
_ = nav.PushAsync(page);
|
||||||
|
var window = new Window { Content = nav };
|
||||||
|
window.Show();
|
||||||
|
|
||||||
// Act: type a title into the editor's TextBox without
|
// Act: type a title into the editor's TextBox without
|
||||||
// first selecting a post in the list — the only state
|
// first selecting a post in the list — the only state in
|
||||||
// in which a new post can be created. Then click Save.
|
// which a new post can be created. Then click Save.
|
||||||
var titleBox = _host.Window.GetVisualDescendants()
|
var titleBox = window.GetVisualDescendants()
|
||||||
.OfType<TextBox>()
|
.OfType<TextBox>()
|
||||||
.First(t => t.PlaceholderText == "Title");
|
.First(t => t.PlaceholderText == "Title");
|
||||||
const string typed = "Mon premier billet";
|
const string typed = "Mon premier billet";
|
||||||
titleBox.Text = typed;
|
titleBox.Text = typed;
|
||||||
|
|
||||||
var saveButton = _host.Window.GetVisualDescendants()
|
var saveButton = window.GetVisualDescendants()
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.Single(b => b.Content as string == "Save");
|
.Single(b => b.Content as string == "Save");
|
||||||
saveButton.Command!.Execute(null);
|
saveButton.Command!.Execute(null);
|
||||||
|
|
@ -72,11 +75,7 @@ public sealed class MainPageSaveTests
|
||||||
// task on the dispatcher. Give the dispatcher a chance to
|
// task on the dispatcher. Give the dispatcher a chance to
|
||||||
// run so the awaited CallAsync has actually fired before
|
// run so the awaited CallAsync has actually fired before
|
||||||
// we inspect the recorder.
|
// we inspect the recorder.
|
||||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
await Task.Delay(200);
|
||||||
while (recorder.Calls.Count == 0 && DateTime.UtcNow < deadline)
|
|
||||||
{
|
|
||||||
Task.Delay(20).GetAwaiter().GetResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert: the first POST to "blog" carried a BlogPostDto
|
// Assert: the first POST to "blog" carried a BlogPostDto
|
||||||
// whose Title is exactly what the user typed. The bug
|
// whose Title is exactly what the user typed. The bug
|
||||||
234
src/PostIt.Tests/PostAclDialogTests.cs
Normal file
234
src/PostIt.Tests/PostAclDialogTests.cs
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for the user-reported bug:
|
||||||
|
/// <c>PostAclDialogViewModel.LoadAsync</c> was never invoked,
|
||||||
|
/// so <c>MyCircles</c> and <c>AclEntries</c> were empty when the
|
||||||
|
/// dialog opened (the dropdown showed "Choisir un cercle..." and
|
||||||
|
/// the list was blank, with no error to hint at why).
|
||||||
|
///
|
||||||
|
/// <para>The fix wires <see cref="PostAclDialog"/>'s constructor
|
||||||
|
/// to trigger <c>LoadAsync</c> on the first
|
||||||
|
/// <c>AttachedToVisualTree</c>, and the VM guards re-entry via
|
||||||
|
/// <c>_loaded</c>. Two tests pin that contract:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>LoadAsync_runs_once_on_visual_attachment</c>: HTTP
|
||||||
|
/// traffic shows up after the dialog is mounted.</item>
|
||||||
|
/// <item><c>LoadAsync_is_idempotent</c>: a second explicit call
|
||||||
|
/// to <c>LoadAsync</c> on the same VM hits the HTTP layer only
|
||||||
|
/// once (the <c>_loaded</c> gate).</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>HTTP is stubbed with a counter
|
||||||
|
/// <see cref="HttpMessageHandler"/> that returns canned JSON
|
||||||
|
/// <c>[]</c> 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 <c>BearerScopeTests</c>: real
|
||||||
|
/// <see cref="YavscApiClient"/> subclass, real
|
||||||
|
/// <see cref="HttpClient"/> with an injected handler, real
|
||||||
|
/// <see cref="BlogAclApiClient"/> / <see cref="CircleApiClient"/>
|
||||||
|
/// talking to it.</para>
|
||||||
|
/// </summary>
|
||||||
|
public class PostAclDialogTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="HttpMessageHandler"/> that replies 200 with
|
||||||
|
/// <c>[]</c> (a valid JSON empty array, which both
|
||||||
|
/// <c>GetMyAclAsync</c> and <c>GetMyCirclesAsync</c> can
|
||||||
|
/// deserialize) and counts the number of requests.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CountingHttpHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public int RequestCount { get; private set; }
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
RequestCount++;
|
||||||
|
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
return Task.FromResult(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
|
||||||
|
/// traffic through a caller-supplied
|
||||||
|
/// <see cref="HttpMessageHandler"/>. Same recipe as
|
||||||
|
/// <c>BearerScopeTests.TestableYavscApiClient</c> — we
|
||||||
|
/// override <c>CallAsync{T}</c> to talk to our own
|
||||||
|
/// <see cref="HttpClient"/> and skip the OIDC refresh path,
|
||||||
|
/// because the load-on-attach bug has nothing to do with
|
||||||
|
/// token refresh.
|
||||||
|
/// </summary>
|
||||||
|
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<T> CallAsync<T>(
|
||||||
|
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<T>(stream,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
return Task.FromResult(dto!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a minimal DI graph exposing the two API clients
|
||||||
|
/// (backed by a stub HTTP handler) and the page itself, so
|
||||||
|
/// <c>ViewLocator</c> 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 <c>App.PushPageAsync</c> path.
|
||||||
|
/// The DI graph is built into a local <see cref="IServiceProvider"/>
|
||||||
|
/// that is NOT attached to <see cref="App.ServiceProvider"/>:
|
||||||
|
/// rebinding the global DI mid-test would trample the
|
||||||
|
/// Settings singleton the rest of the harness depends on.
|
||||||
|
/// </summary>
|
||||||
|
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<PostAclDialog>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using PostIt.ViewModels;
|
|
||||||
using PostIt.Views;
|
|
||||||
using Yavsc.Api.Client;
|
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// xUnit collection grouping every headless UI test in
|
|
||||||
/// <c>PostIt.Tests</c>. The Avalonia headless harness instantiates
|
|
||||||
/// a single <see cref="PostItHeadlessFixture"/> per test class
|
|
||||||
/// (<c>IClassFixture<PostItHeadlessFixture></c>); the
|
|
||||||
/// collection marker here exists for two reasons:
|
|
||||||
///
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item><description>It documents the shared lifecycle
|
|
||||||
/// contract: every test class that opts in gets the same
|
|
||||||
/// <see cref="MainWindow"/>, the same DI service provider,
|
|
||||||
/// the same <see cref="ViewLocator"/> on
|
|
||||||
/// <see cref="Application.DataTemplates"/>, and the same
|
|
||||||
/// <see cref="PostIt.App.MainWindow"/> attachment that
|
|
||||||
/// <c>App.PushPageAsync</c> relies on.</description></item>
|
|
||||||
/// <item><description>It disables parallelisation across the
|
|
||||||
/// whole collection. The Avalonia headless platform is
|
|
||||||
/// process-global (one <see cref="Application.Current"/> per
|
|
||||||
/// process, one dispatcher per thread), so two collection
|
|
||||||
/// members running in parallel would race on the same
|
|
||||||
/// static state and produce flaky failures with no useful
|
|
||||||
/// diagnostic. Same rationale as
|
|
||||||
/// <c>JwtClaimMappingCollection</c>.</description></item>
|
|
||||||
/// </list>
|
|
||||||
///
|
|
||||||
/// Mirrors the convention used by
|
|
||||||
/// <c>Yavsc.Org.Tests.WebServerFixture</c> (collection
|
|
||||||
/// <c>"Yavsc Server"</c>) and
|
|
||||||
/// <c>Yavsc.Blogs.Tests.JwtClaimMappingCollection</c>.
|
|
||||||
/// </summary>
|
|
||||||
[CollectionDefinition("PostIt Headless")]
|
|
||||||
public sealed class PostItHeadlessCollection: IDisposable
|
|
||||||
{
|
|
||||||
|
|
||||||
/// <summary>The DI service provider the fixture booted
|
|
||||||
/// (production graph from <see cref="App.BuildServices"/>).
|
|
||||||
/// Identical across every <see cref="PostItHeadlessFixture"/>
|
|
||||||
/// instance — see class remarks.</summary>
|
|
||||||
public IServiceProvider Services { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>The headless <see cref="MainWindow"/> for this
|
|
||||||
/// fixture instance. Already <see cref="WindowBase.Show"/>n,
|
|
||||||
/// so its visual tree is realised and
|
|
||||||
/// <see cref="Button.Command"/> bindings have been
|
|
||||||
/// evaluated. The window is per-instance, not
|
|
||||||
/// process-shared, so each test class gets a clean nav
|
|
||||||
/// stack out of the box.</summary>
|
|
||||||
public MainWindow Window { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>The <see cref="App"/> instance the Avalonia
|
|
||||||
/// headless harness set as <see cref="Application.Current"/>.
|
|
||||||
/// Convenience accessor for tests that need to call
|
|
||||||
/// <c>App.PushPageAsync</c> directly.</summary>
|
|
||||||
public App App { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>The navigation surface the
|
|
||||||
/// <see cref="MainWindow"/> hosts. Tests can read
|
|
||||||
/// <c>NavigationStack</c> directly or call
|
|
||||||
/// <see cref="PushAsync"/> to push onto it.</summary>
|
|
||||||
public NavigationPage NavRoot { get; private set; }
|
|
||||||
|
|
||||||
public PostItHeadlessCollection()
|
|
||||||
{
|
|
||||||
|
|
||||||
// First fixture to construct in this process:
|
|
||||||
// build the production DI container and attach
|
|
||||||
// it to the App.
|
|
||||||
App = (App)Application.Current!;
|
|
||||||
|
|
||||||
var testingServices = new ServiceCollection();
|
|
||||||
var api = new RecordingYavscApiClient(new CallRecorder());
|
|
||||||
var blog = new BlogApiClient(api, "http://localhost/");
|
|
||||||
|
|
||||||
// The recording fake is sufficient on its own — no
|
|
||||||
// production wiring needed. Swap it in via the fixture
|
|
||||||
// so the App.PushPageAsync path resolves the same way
|
|
||||||
// it would in production (minus the token store).
|
|
||||||
|
|
||||||
testingServices.AddSingleton(api);
|
|
||||||
testingServices.AddSingleton(blog);
|
|
||||||
|
|
||||||
Services = App.BuildServices(testingServices);
|
|
||||||
|
|
||||||
App.AttachServiceProvider(Services);
|
|
||||||
|
|
||||||
// The ViewLocator is the single entry point
|
|
||||||
// App.PushPageAsync uses to map a VM to a Page.
|
|
||||||
App.DataTemplates.Clear();
|
|
||||||
App.DataTemplates.Add(new ViewLocator(Services));
|
|
||||||
|
|
||||||
// Per-instance window: each test class gets its own.
|
|
||||||
Window = new MainWindow();
|
|
||||||
App.AttachMainWindow(Window);
|
|
||||||
Window.Show();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Push a view model or page onto <see cref="NavRoot"/>.
|
|
||||||
/// Awaits the push asynchronously so the headless
|
|
||||||
/// dispatcher can pump frames while the push is in flight;
|
|
||||||
/// the caller can then assert on the resulting stack
|
|
||||||
/// (<c>NavRoot.NavigationStack[^1]</c>).
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Tests that want a clean stack (most of them, since
|
|
||||||
/// the fixture's <see cref="MainWindow"/> is shared
|
|
||||||
/// across every test class) should call
|
|
||||||
/// <see cref="ClearNavigationStack"/> before pushing, or
|
|
||||||
/// use <see cref="MountAsync"/> which clears by default.
|
|
||||||
/// </remarks>
|
|
||||||
/// <returns>The page that was pushed, so the caller can
|
|
||||||
/// assert on its type or bind a <c>DataContext</c>.</returns>
|
|
||||||
public Page PushAsync(object vmOrPage)
|
|
||||||
{
|
|
||||||
if (vmOrPage is null) throw new ArgumentNullException(nameof(vmOrPage));
|
|
||||||
|
|
||||||
|
|
||||||
// Synchronous push: the Avalonia headless
|
|
||||||
// NavigationPage.PushAsync returns a Task that
|
|
||||||
// completes once the transition animation finishes,
|
|
||||||
// and in headless that animation is driven by the
|
|
||||||
// dispatcher pump. We block on the Task with
|
|
||||||
// GetAwaiter().GetResult() rather than awaiting it
|
|
||||||
// because the test body is itself running on the
|
|
||||||
// dispatcher thread (the [AvaloniaFact] attribute
|
|
||||||
// schedules the test there); an await would capture
|
|
||||||
// the dispatcher as the continuation target and
|
|
||||||
// deadlock waiting for the push to complete on a
|
|
||||||
// thread that's already busy running the test.
|
|
||||||
if (vmOrPage is Page page)
|
|
||||||
{
|
|
||||||
Window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
|
|
||||||
// Pump the dispatcher once so the pushed page
|
|
||||||
// is actually on NavigationStack (the Awaiter
|
|
||||||
// above unblocks before the stack is updated).
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
// VM push: route through the production
|
|
||||||
// App.PushPageAsync pipeline.
|
|
||||||
var app = (App)Application.Current!;
|
|
||||||
var vm = (ViewModelBase)vmOrPage;
|
|
||||||
app.PushPageAsync(vm).GetAwaiter().GetResult();
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
return Window.NavRoot.NavigationStack[^1];
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
// Last fixture out: tear the shared state down so
|
|
||||||
// the next test run starts clean. We don't shut
|
|
||||||
// down the Avalonia headless platform — that's
|
|
||||||
// owned by the [AvaloniaTestApplication] attribute
|
|
||||||
// on TestAppBuilder and gets torn down when the
|
|
||||||
// process exits.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var app = (App)Application.Current!;
|
|
||||||
app.DataTemplates.Clear();
|
|
||||||
}
|
|
||||||
catch { /* best effort */ }
|
|
||||||
Services = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +1,46 @@
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Headless.XUnit;
|
using Avalonia.Headless.XUnit;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Styling;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
|
using PostIt.Views;
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// UI tests for <see cref="SessionStatusBanner"/>. The shared
|
/// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside
|
||||||
/// <see cref="PostItHeadlessFixture"/> provides the headless
|
/// a real <see cref="MainWindow"/> via the headless Avalonia
|
||||||
/// <see cref="MainWindow"/> already attached to <see cref="App"/>
|
/// platform declared in <c>TestApp.cs</c>.
|
||||||
/// and shown, so each test only has to wire its
|
|
||||||
/// <see cref="SessionStatusViewModel"/> onto
|
|
||||||
/// <c>MainWindow.SessionBanner</c> and assert on the rendered
|
|
||||||
/// tree.
|
|
||||||
///
|
///
|
||||||
/// <para>The session banner's <c>DataContext</c> is not wired
|
/// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c>
|
||||||
/// by <see cref="App.OnFrameworkInitializationCompleted"/> in
|
/// established: a test attribute <c>[AvaloniaFact]</c> (from
|
||||||
/// these tests: production wires it at composition time, but a
|
/// <c>Avalonia.Headless.XUnit</c>) instead of plain <c>[Fact]</c>,
|
||||||
/// unit test runs against a freshly-built <see cref="App"/> so
|
/// <c>new MainWindow()</c>, <c>window.Show()</c>. The AvaloniaFact
|
||||||
/// we set the <c>DataContext</c> on the banner directly. The
|
/// attribute schedules the test body inside a dispatcher, which
|
||||||
/// production code path is exercised end-to-end by the manual
|
/// is the precondition for the headless Window's
|
||||||
/// launch, not here.</para>
|
|
||||||
///
|
|
||||||
/// <para>Pattern: <c>[AvaloniaFact]</c> (from
|
|
||||||
/// <c>Avalonia.Headless.XUnit</c>) instead of plain
|
|
||||||
/// <c>[Fact]</c> because the AvaloniaFact attribute schedules
|
|
||||||
/// the test body inside a dispatcher, which is the precondition
|
|
||||||
/// for the headless Window's
|
|
||||||
/// <c>PlatformManager.CreateWindow()</c> to find a registered
|
/// <c>PlatformManager.CreateWindow()</c> to find a registered
|
||||||
/// service. A plain <c>[Fact]</c> test that calls
|
/// service. A plain <c>[Fact]</c> test that calls
|
||||||
/// <c>new Window().Show()</c> throws because the harness has
|
/// <c>new Window().Show()</c> throws because the harness has not
|
||||||
/// not been initialised for that thread.</para>
|
/// been initialised for that thread.</para>
|
||||||
|
///
|
||||||
|
/// <para>The session banner's <c>DataContext</c> is not wired in
|
||||||
|
/// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
|
||||||
|
/// not called in a unit test, so we set the DataContext on the
|
||||||
|
/// banner directly. The production code path is exercised
|
||||||
|
/// end-to-end by the manual launch, not here.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("PostIt Headless")]
|
public class SessionStatusBannerTests
|
||||||
public sealed class SessionStatusBannerTests
|
|
||||||
{
|
{
|
||||||
private readonly PostItHeadlessCollection _host;
|
|
||||||
|
|
||||||
public SessionStatusBannerTests(PostItHeadlessCollection host)
|
|
||||||
{
|
|
||||||
_host = host;
|
|
||||||
}
|
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Banner_renders_three_buttons_in_the_visual_tree()
|
public void Banner_renders_three_buttons_in_the_visual_tree()
|
||||||
{
|
{
|
||||||
var banner = _host.Window.SessionBanner;
|
var window = new MainWindow();
|
||||||
banner.DataContext = new SessionStatusViewModel();
|
window.SessionBanner.DataContext = new SessionStatusViewModel();
|
||||||
|
window.Show();
|
||||||
|
|
||||||
var buttons = banner.GetVisualDescendants()
|
var buttons = window.SessionBanner.GetVisualDescendants()
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|
@ -65,30 +57,31 @@ public sealed class SessionStatusBannerTests
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Banner_login_button_is_visible_when_logged_out()
|
public void Banner_login_button_is_visible_when_logged_out()
|
||||||
{
|
{
|
||||||
var banner = _host.Window.SessionBanner;
|
var window = new MainWindow();
|
||||||
var vm = new SessionStatusViewModel();
|
var vm = new SessionStatusViewModel();
|
||||||
Assert.True(vm.IsLoggedOut); // VM default
|
Assert.True(vm.IsLoggedOut); // VM default
|
||||||
banner.DataContext = vm;
|
window.SessionBanner.DataContext = vm;
|
||||||
|
window.Show();
|
||||||
|
|
||||||
var login = banner.GetVisualDescendants()
|
var login = window.SessionBanner.GetVisualDescendants()
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.Single(b => b.Content as string == "Se connecter");
|
.Single(b => b.Content as string == "Se connecter");
|
||||||
|
|
||||||
// The XAML binds IsVisible to IsLoggedOut. After the
|
// The XAML binds IsVisible to IsLoggedOut. After Show,
|
||||||
// banner is on the realised visual tree, the binding
|
// the binding has been evaluated.
|
||||||
// has been evaluated.
|
|
||||||
Assert.True(login.IsVisible);
|
Assert.True(login.IsVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Banner_logout_button_is_hidden_when_logged_out()
|
public void Banner_logout_button_is_hidden_when_logged_out()
|
||||||
{
|
{
|
||||||
var banner = _host.Window.SessionBanner;
|
var window = new MainWindow();
|
||||||
var vm = new SessionStatusViewModel();
|
var vm = new SessionStatusViewModel();
|
||||||
Assert.False(vm.IsLoggedIn); // VM default
|
Assert.False(vm.IsLoggedIn); // VM default
|
||||||
banner.DataContext = vm;
|
window.SessionBanner.DataContext = vm;
|
||||||
|
window.Show();
|
||||||
|
|
||||||
var logout = banner.GetVisualDescendants()
|
var logout = window.SessionBanner.GetVisualDescendants()
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.Single(b => b.Content as string == "Se déconnecter");
|
.Single(b => b.Content as string == "Se déconnecter");
|
||||||
|
|
||||||
|
|
@ -98,10 +91,11 @@ public sealed class SessionStatusBannerTests
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Banner_settings_button_is_visible_regardless_of_session()
|
public void Banner_settings_button_is_visible_regardless_of_session()
|
||||||
{
|
{
|
||||||
var banner = _host.Window.SessionBanner;
|
var window = new MainWindow();
|
||||||
banner.DataContext = new SessionStatusViewModel();
|
window.SessionBanner.DataContext = new SessionStatusViewModel();
|
||||||
|
window.Show();
|
||||||
|
|
||||||
var settings = banner.GetVisualDescendants()
|
var settings = window.SessionBanner.GetVisualDescendants()
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.Single(b => b.Content as string == "Paramètres");
|
.Single(b => b.Content as string == "Paramètres");
|
||||||
|
|
||||||
|
|
@ -114,10 +108,11 @@ public sealed class SessionStatusBannerTests
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Banner_session_label_reflects_DataContext()
|
public void Banner_session_label_reflects_DataContext()
|
||||||
{
|
{
|
||||||
var banner = _host.Window.SessionBanner;
|
var window = new MainWindow();
|
||||||
banner.DataContext = new SessionStatusViewModel();
|
window.SessionBanner.DataContext = new SessionStatusViewModel();
|
||||||
|
window.Show();
|
||||||
|
|
||||||
var label = banner.GetVisualDescendants()
|
var label = window.SessionBanner.GetVisualDescendants()
|
||||||
.OfType<TextBlock>()
|
.OfType<TextBlock>()
|
||||||
.First(t => t.Text == "Déconnecté" || t.Text == "Connecté");
|
.First(t => t.Text == "Déconnecté" || t.Text == "Connecté");
|
||||||
|
|
||||||
|
|
|
||||||
16
src/PostIt.Tests/UnitTest1.cs
Normal file
16
src/PostIt.Tests/UnitTest1.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
using Avalonia.Headless.XUnit;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using PostIt.Views;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
public class MainPageTests
|
||||||
|
{
|
||||||
|
[AvaloniaFact]
|
||||||
|
public void MainPage_Should_Load()
|
||||||
|
{
|
||||||
|
var window = new MainWindow();
|
||||||
|
window.Show();
|
||||||
|
Assert.NotNull(window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ public partial class AddCircleMemberDialog : ContentPage
|
||||||
public AddCircleMemberDialog()
|
public AddCircleMemberDialog()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent()
|
private void InitializeComponent()
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ namespace Yavsc.Blogs.Tests;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Behavioural tests for <c>BlogAclApiController.PostCircleAuthorizationToBlogPost</c>:
|
/// Behavioural tests for <c>BlogAclApiController.PostCircleAuthorizationToBlogPost</c>:
|
||||||
/// <c>POST /api/v1/blogacl</c> with a JSON body of
|
/// <c>POST /api/v1/blogacl</c> with a JSON body of
|
||||||
/// <c>CircleAuthorizationToBlogPost</c> (CircleId + BlogPostId + Comment).
|
/// <c>CircleAuthorizationToBlogPost</c> (CircleId + BlogPostId).
|
||||||
///
|
///
|
||||||
/// <para>Same fixture as <see cref="CircleMembersApiTests"/>:
|
/// <para>Same fixture as <see cref="CircleMembersApiTests"/>:
|
||||||
/// <see cref="BlogsWebServerFixture"/> provides a SQLite
|
/// <see cref="BlogsWebServerFixture"/> provides a SQLite
|
||||||
|
|
@ -69,11 +69,11 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
/// <summary>Create a circle owned by <paramref name="ownerId"/>
|
/// <summary>Create a circle owned by <paramref name="ownerId"/>
|
||||||
/// directly in the SQLite store and return its server-assigned
|
/// directly in the SQLite store and return its server-assigned
|
||||||
/// id.</summary>
|
/// id.</summary>
|
||||||
private long SeedCircle(string ownerId, string name)
|
private long SeedCircle(string ownerId, string name, bool isPublic = false)
|
||||||
{
|
{
|
||||||
using var scope = _fixture.Services.CreateScope();
|
using var scope = _fixture.Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
var circle = new Circle { OwnerId = ownerId, Name = name };
|
var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic };
|
||||||
db.Circle.Add(circle);
|
db.Circle.Add(circle);
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
return circle.Id;
|
return circle.Id;
|
||||||
|
|
@ -149,7 +149,6 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
{
|
{
|
||||||
CircleId = circleId,
|
CircleId = circleId,
|
||||||
BlogPostId = postId,
|
BlogPostId = postId,
|
||||||
Comment = true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var response = await http.PostAsJsonAsync(BlogAclUrl(), payload);
|
var response = await http.PostAsJsonAsync(BlogAclUrl(), payload);
|
||||||
|
|
@ -158,4 +157,44 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
// CreatedAtRoute("GetCircleAuthorizationToBlogPost", ...)).
|
// CreatedAtRoute("GetCircleAuthorizationToBlogPost", ...)).
|
||||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reproduces the prod 500 logged on 2026-08-21 on mercure:
|
||||||
|
/// <c>InvalidOperationException: The value of
|
||||||
|
/// 'CircleAuthorizationToBlogPost.BlogPostId' is unknown</c>
|
||||||
|
/// when <see cref="PostAclDialogViewModel.AddAsync"/> POSTs the
|
||||||
|
/// shape <c>{ "circleId": <id> }</c> — the exact body the
|
||||||
|
/// PostIt client builds from <see cref="CircleAuthorization"/>
|
||||||
|
/// (which only carries <c>CircleId</c>). The server deserialises
|
||||||
|
/// it into <see cref="CircleAuthorizationToBlogPost"/>, leaves
|
||||||
|
/// <c>BlogPostId</c> at its <c>default(long) = 0</c>, attaches
|
||||||
|
/// no <c>Target</c> navigation, and EF Core refuses to INSERT
|
||||||
|
/// during <c>PrepareToSave()</c>. The fix lives in PostIt
|
||||||
|
/// (enrich the payload with <c>blogPostId</c> + <c>comment</c>)
|
||||||
|
/// and on the wire DTO (<see cref="CircleAuthorization"/> must
|
||||||
|
/// carry those fields); the server validates. Until that ships,
|
||||||
|
/// this test stays red.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape_against_existing_circle_named_test()
|
||||||
|
{
|
||||||
|
ResetDatabaseWithAlice();
|
||||||
|
// The prod circle already exists with Name="test", Public=true,
|
||||||
|
// owned by the caller. We seed the same shape pre-POST so the
|
||||||
|
// test reproduces the prod scenario end-to-end.
|
||||||
|
var circleId = SeedCircle("alice", "test", isPublic: true);
|
||||||
|
var postId = SeedBlogPost("alice", "Billet ACL test");
|
||||||
|
using var http = NewClient("alice");
|
||||||
|
|
||||||
|
// Exact wire shape PostIt sends today:
|
||||||
|
// { "circleId": <id> } — no blogPostId, no comment.
|
||||||
|
var payload = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["circleId"] = circleId,
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await http.PostAsJsonAsync(BlogAclUrl(), payload);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4645
src/Yavsc.Org/Migrations/20260820232152_DropCommentFromCircleAuthorizationToBlogPost.Designer.cs
generated
Normal file
4645
src/Yavsc.Org/Migrations/20260820232152_DropCommentFromCircleAuthorizationToBlogPost.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,29 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Yavsc.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class DropCommentFromCircleAuthorizationToBlogPost : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Comment",
|
||||||
|
table: "CircleAuthorizationToBlogPost");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "Comment",
|
||||||
|
table: "CircleAuthorizationToBlogPost",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -476,9 +476,6 @@ namespace Yavsc.Migrations
|
||||||
b.Property<int>("ClientId")
|
b.Property<int>("ClientId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("ClientId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("GrantType")
|
b.Property<string>("GrantType")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
|
@ -486,8 +483,6 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
b.HasIndex("ClientId");
|
b.HasIndex("ClientId");
|
||||||
|
|
||||||
b.HasIndex("ClientId1");
|
|
||||||
|
|
||||||
b.ToTable("ClientGrantTypes");
|
b.ToTable("ClientGrantTypes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -583,9 +578,6 @@ namespace Yavsc.Migrations
|
||||||
b.Property<int>("ClientId")
|
b.Property<int>("ClientId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("ClientId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("RedirectUri")
|
b.Property<string>("RedirectUri")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
|
@ -593,8 +585,6 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
b.HasIndex("ClientId");
|
b.HasIndex("ClientId");
|
||||||
|
|
||||||
b.HasIndex("ClientId1");
|
|
||||||
|
|
||||||
b.ToTable("ClientRedirectUris");
|
b.ToTable("ClientRedirectUris");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -609,9 +599,6 @@ namespace Yavsc.Migrations
|
||||||
b.Property<int>("ClientId")
|
b.Property<int>("ClientId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("ClientId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Scope")
|
b.Property<string>("Scope")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
|
@ -619,8 +606,6 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
b.HasIndex("ClientId");
|
b.HasIndex("ClientId");
|
||||||
|
|
||||||
b.HasIndex("ClientId1");
|
|
||||||
|
|
||||||
b.ToTable("ClientScopes");
|
b.ToTable("ClientScopes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1096,9 +1081,6 @@ namespace Yavsc.Migrations
|
||||||
b.Property<long>("BlogPostId")
|
b.Property<long>("BlogPostId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<bool>("Comment")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.HasKey("CircleId", "BlogPostId");
|
b.HasKey("CircleId", "BlogPostId");
|
||||||
|
|
||||||
b.HasIndex("BlogPostId");
|
b.HasIndex("BlogPostId");
|
||||||
|
|
@ -3460,16 +3442,12 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b =>
|
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null)
|
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
||||||
.WithMany("AllowedGrantTypes")
|
.WithMany("AllowedGrantTypes")
|
||||||
.HasForeignKey("ClientId")
|
.HasForeignKey("ClientId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ClientId1");
|
|
||||||
|
|
||||||
b.Navigation("Client");
|
b.Navigation("Client");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3520,31 +3498,23 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientRedirectUri", b =>
|
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientRedirectUri", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null)
|
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
||||||
.WithMany("RedirectUris")
|
.WithMany("RedirectUris")
|
||||||
.HasForeignKey("ClientId")
|
.HasForeignKey("ClientId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ClientId1");
|
|
||||||
|
|
||||||
b.Navigation("Client");
|
b.Navigation("Client");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientScope", b =>
|
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientScope", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null)
|
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
||||||
.WithMany("AllowedScopes")
|
.WithMany("AllowedScopes")
|
||||||
.HasForeignKey("ClientId")
|
.HasForeignKey("ClientId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ClientId1");
|
|
||||||
|
|
||||||
b.Navigation("Client");
|
b.Navigation("Client");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,5 @@ namespace Yavsc.Models.Access
|
||||||
[ForeignKey("CircleId")]
|
[ForeignKey("CircleId")]
|
||||||
public virtual Circle Allowed { get; set; }
|
public virtual Circle Allowed { get; set; }
|
||||||
|
|
||||||
public bool Comment { get; set; }
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
yavsc.sln
30
yavsc.sln
|
|
@ -31,14 +31,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Browser", "src\PostI
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Desktop", "src\PostIt\PostIt.Desktop\PostIt.Desktop.csproj", "{EFE24256-9335-44C5-8B77-E180C2DB3C0B}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Desktop", "src\PostIt\PostIt.Desktop\PostIt.Desktop.csproj", "{EFE24256-9335-44C5-8B77-E180C2DB3C0B}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt.Tests\PostIt.Tests.csproj", "{4D283324-6DD3-4CD1-9893-8C317772C6B5}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Blogs.Tests", "src\Yavsc.Blogs.Tests\Yavsc.Blogs.Tests.csproj", "{0E471075-DABF-40E9-98B7-1630BEF19145}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Blogs.Tests", "src\Yavsc.Blogs.Tests\Yavsc.Blogs.Tests.csproj", "{0E471075-DABF-40E9-98B7-1630BEF19145}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Tests.Shared", "src\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj", "{34D1F73D-BF74-47CC-9358-9F4F221C75D7}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Tests.Shared", "src\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj", "{34D1F73D-BF74-47CC-9358-9F4F221C75D7}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Client", "src\Yavsc.Api.Client\Yavsc.Api.Client.csproj", "{59AF5DEA-D349-495A-BC44-FC7BD4E55099}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Client", "src\Yavsc.Api.Client\Yavsc.Api.Client.csproj", "{59AF5DEA-D349-495A-BC44-FC7BD4E55099}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt.Tests\PostIt.Tests.csproj", "{838B9737-88CA-432E-835C-F96817CF8085}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
|
@ -181,18 +181,6 @@ Global
|
||||||
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x64.Build.0 = Release|Any CPU
|
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x86.ActiveCfg = Release|Any CPU
|
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x86.Build.0 = Release|Any CPU
|
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
|
@ -229,6 +217,18 @@ Global
|
||||||
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x64.Build.0 = Release|Any CPU
|
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.ActiveCfg = Release|Any CPU
|
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.Build.0 = Release|Any CPU
|
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|
@ -246,9 +246,9 @@ Global
|
||||||
{4C092CF8-524A-494D-AAFC-69383DFBA31D} = {E13D107F-4053-D0DE-6394-453609595BFE}
|
{4C092CF8-524A-494D-AAFC-69383DFBA31D} = {E13D107F-4053-D0DE-6394-453609595BFE}
|
||||||
{AF96C1C4-D128-4CD7-A8BB-D194E6D270F0} = {E13D107F-4053-D0DE-6394-453609595BFE}
|
{AF96C1C4-D128-4CD7-A8BB-D194E6D270F0} = {E13D107F-4053-D0DE-6394-453609595BFE}
|
||||||
{EFE24256-9335-44C5-8B77-E180C2DB3C0B} = {E13D107F-4053-D0DE-6394-453609595BFE}
|
{EFE24256-9335-44C5-8B77-E180C2DB3C0B} = {E13D107F-4053-D0DE-6394-453609595BFE}
|
||||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
|
||||||
{0E471075-DABF-40E9-98B7-1630BEF19145} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
{0E471075-DABF-40E9-98B7-1630BEF19145} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||||
{34D1F73D-BF74-47CC-9358-9F4F221C75D7} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
{34D1F73D-BF74-47CC-9358-9F4F221C75D7} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||||
{59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
{59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||||
|
{838B9737-88CA-432E-835C-F96817CF8085} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue