Compare commits
2 commits
0e7576857d
...
b3056f1c2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
b3056f1c2e |
|||
|
1b289c1387 |
13 changed files with 154 additions and 43 deletions
|
|
@ -97,7 +97,7 @@ public class BearerScopeTests
|
|||
// CapturingHttpHandler is the assertion point. It
|
||||
// records the first request's Authorization header and
|
||||
// returns 200 with an empty array (BlogApiClient
|
||||
// deserialises to List<BlogPost>).
|
||||
// deserialises to List<BlogPostDto>).
|
||||
var captured = new CapturingHttpHandler();
|
||||
var client = new YavscApiClient(
|
||||
settings,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ internal sealed class CallRecorder
|
|||
|
||||
/// <summary>Test fake that records every CallAsync invocation
|
||||
/// and answers them with a canned sequence: the first call gets
|
||||
/// a server-issued BlogPost (Id=42), the second call gets a
|
||||
/// a server-issued BlogPostDto (Id=42), the second call gets a
|
||||
/// single-element list containing that post. Used by the ViewModel
|
||||
/// tests and the headless UI test to capture exactly what the
|
||||
/// Save button posts to the server.</summary>
|
||||
|
|
@ -44,20 +44,20 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
|
|||
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
_recorder.Calls.Add((method, path, body));
|
||||
// BlogPost? boxes to BlogPost at runtime, so we test the
|
||||
// non-nullable type — typeof(BlogPost?) is a C# error
|
||||
// BlogPostDto? boxes to BlogPostDto at runtime, so we test the
|
||||
// non-nullable type — typeof(BlogPostDto?) is a C# error
|
||||
// (CS8639: "typeof cannot be used on a nullable reference
|
||||
// type").
|
||||
if (typeof(T) == typeof(BlogPost))
|
||||
return Task.FromResult((T)(object)new BlogPost
|
||||
if (typeof(T) == typeof(BlogPostDto))
|
||||
return Task.FromResult((T)(object)new BlogPostDto
|
||||
{
|
||||
Id = 42,
|
||||
Title = "Mon premier billet",
|
||||
AuthorId = "tester",
|
||||
Article = "Contenu du billet de test.",
|
||||
});
|
||||
if (typeof(T) == typeof(List<BlogPost>))
|
||||
return Task.FromResult((T)(object)new List<BlogPost>
|
||||
if (typeof(T) == typeof(List<BlogPostDto>))
|
||||
return Task.FromResult((T)(object)new List<BlogPostDto>
|
||||
{
|
||||
new() { Id = 42, Title = "Mon premier billet" }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ namespace PostIt.Tests;
|
|||
/// in which a brand-new post can be created), the binding has
|
||||
/// no target and the user's keystrokes are silently dropped.
|
||||
/// Clicking "Save" then routes to the VM branch
|
||||
/// <c>if (SelectedPost is null) { new BlogPost { Title = string.Empty, ... } }</c>
|
||||
/// <c>if (SelectedPost is null) { new BlogPostDto { Title = string.Empty, ... } }</c>
|
||||
/// which the controller rejects with 400 "The Title field is
|
||||
/// required." This test fails on that branch today and will
|
||||
/// pass once the VM owns a dedicated <c>Title</c>/<c>Article</c>
|
||||
|
|
@ -77,14 +77,14 @@ public class MainPageSaveTests
|
|||
// we inspect the recorder.
|
||||
await Task.Delay(200);
|
||||
|
||||
// Assert: the first POST to "blog" carried a BlogPost
|
||||
// Assert: the first POST to "blog" carried a BlogPostDto
|
||||
// whose Title is exactly what the user typed. The bug
|
||||
// fails this assertion with Title == string.Empty.
|
||||
Assert.NotEmpty(recorder.Calls);
|
||||
var (method, path, body) = recorder.FirstCall;
|
||||
Assert.Equal(HttpMethod.Post, method);
|
||||
Assert.Equal("blog", path);
|
||||
var sent = Assert.IsType<BlogPost>(body);
|
||||
var sent = Assert.IsType<BlogPostDto>(body);
|
||||
Assert.Equal(typed, sent.Title);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ public class PostItViewModelTests
|
|||
var blog = new BlogApiClient(fakeApi, "http://localhost/");
|
||||
var viewModel = new MainPageViewModel(blog);
|
||||
|
||||
viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
|
||||
viewModel.Posts.Add(new BlogPost { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
|
||||
viewModel.Posts.Add(new BlogPost { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
|
||||
viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
|
||||
viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
|
||||
viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
|
||||
|
||||
viewModel.SearchText = "search";
|
||||
viewModel.SearchCommand.Execute(null);
|
||||
|
|
@ -41,7 +41,7 @@ public class PostItViewModelTests
|
|||
// The new BlogApiClient delegates transport to YavscApiClient.
|
||||
// We feed it a fake YavscApiClient that returns the expected
|
||||
// list straight from CallAsync.
|
||||
var expected = new List<BlogPost>
|
||||
var expected = new List<BlogPostDto>
|
||||
{
|
||||
new() { Id = 1, Title = "Hello" },
|
||||
new() { Id = 2, Title = "World" }
|
||||
|
|
@ -77,8 +77,8 @@ public class PostItViewModelTests
|
|||
/// <summary>Test fake that hands back a canned list of posts from any CallAsync.</summary>
|
||||
private sealed class StubYavscApiClient : YavscApiClient
|
||||
{
|
||||
private readonly List<BlogPost> _posts;
|
||||
public StubYavscApiClient(List<BlogPost> posts)
|
||||
private readonly List<BlogPostDto> _posts;
|
||||
public StubYavscApiClient(List<BlogPostDto> posts)
|
||||
: base(
|
||||
new Settings
|
||||
{
|
||||
|
|
@ -98,7 +98,7 @@ public class PostItViewModelTests
|
|||
{
|
||||
// The canned fake only knows about a list of posts; the
|
||||
// BlogApiClient test asserts on that list directly.
|
||||
if (typeof(T) == typeof(List<BlogPost>))
|
||||
if (typeof(T) == typeof(List<BlogPostDto>))
|
||||
return Task.FromResult((T)(object)_posts);
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
/// previous "{Binding SelectedPost.Title}" binding, the user's
|
||||
/// keystrokes were silently dropped whenever
|
||||
/// <c>SelectedPost was null</c>, which made the editor a trap
|
||||
/// and caused Save to POST a <c>BlogPost</c> with an empty
|
||||
/// and caused Save to POST a <c>BlogPostDto</c> with an empty
|
||||
/// title — hence the 400 "The Title field is required".</summary>
|
||||
[ObservableProperty]
|
||||
public partial string DraftTitle { get; set; }
|
||||
|
|
@ -47,13 +47,13 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
public partial string SearchText { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<BlogPost> Posts { get; set; }
|
||||
public partial ObservableCollection<BlogPostDto> Posts { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<BlogPost> FilteredPosts { get; set; }
|
||||
public partial ObservableCollection<BlogPostDto> FilteredPosts { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial BlogPost? SelectedPost { get; set; }
|
||||
public partial BlogPostDto? SelectedPost { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
|
@ -83,8 +83,8 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
private void Init(Settings? settings)
|
||||
{
|
||||
SearchText = string.Empty;
|
||||
Posts = new ObservableCollection<BlogPost>();
|
||||
FilteredPosts = new ObservableCollection<BlogPost>();
|
||||
Posts = new ObservableCollection<BlogPostDto>();
|
||||
FilteredPosts = new ObservableCollection<BlogPostDto>();
|
||||
SelectedPost = null;
|
||||
IsBusy = false;
|
||||
StatusMessage = "Ready";
|
||||
|
|
@ -120,7 +120,7 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
|
||||
partial void OnSearchTextChanged(string value) => ApplyFilter();
|
||||
|
||||
partial void OnSelectedPostChanged(BlogPost? value)
|
||||
partial void OnSelectedPostChanged(BlogPostDto? value)
|
||||
{
|
||||
// Mirror the selection into the editor buffer so the
|
||||
// XAML-bound TextBox/TextEditor show the right content
|
||||
|
|
@ -177,7 +177,7 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
|
||||
await ExecuteAsync(async () =>
|
||||
{
|
||||
// Build a fresh BlogPost from the editor buffer on
|
||||
// Build a fresh BlogPostDto from the editor buffer on
|
||||
// every Save — we no longer mutate SelectedPost in
|
||||
// place. The previous behaviour copied the buffer
|
||||
// (which was a no-op when SelectedPost was null)
|
||||
|
|
@ -189,7 +189,7 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
// the update path.
|
||||
if (SelectedPost is null || SelectedPost.Id == 0)
|
||||
{
|
||||
var draft = new BlogPost
|
||||
var draft = new BlogPostDto
|
||||
{
|
||||
Title = DraftTitle,
|
||||
Article = DraftArticle ?? string.Empty,
|
||||
|
|
@ -205,7 +205,7 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
}
|
||||
else
|
||||
{
|
||||
var update = new BlogPost
|
||||
var update = new BlogPostDto
|
||||
{
|
||||
Id = SelectedPost.Id,
|
||||
AuthorId = SelectedPost.AuthorId,
|
||||
|
|
@ -327,7 +327,7 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
/// because the navigation surface (<c>NavigationPage</c>) lives
|
||||
/// in the View layer.
|
||||
/// </summary>
|
||||
public event EventHandler<BlogPost>? ManageAclRequested;
|
||||
public event EventHandler<BlogPostDto>? ManageAclRequested;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanManageAcl))]
|
||||
public void ManageAcl()
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
|
||||
/// <summary>The post whose ACL is being edited. Set by the
|
||||
/// caller (MainPage) when opening the dialog.</summary>
|
||||
public BlogPost Post { get; }
|
||||
public BlogPostDto Post { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<CircleDto> MyCircles { get; set; } = new();
|
||||
|
|
@ -52,7 +52,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
public partial string StatusMessage { get; set; } = string.Empty;
|
||||
|
||||
public PostAclDialogViewModel(
|
||||
BlogPost post,
|
||||
BlogPostDto post,
|
||||
BlogAclApiClient aclClient,
|
||||
CircleApiClient circleClient)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:BlogPost">
|
||||
<DataTemplate x:DataType="models:BlogPostDto">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ public partial class MainPage : ContentPage
|
|||
}
|
||||
}
|
||||
|
||||
void OnManageAclRequested(object? sender, BlogPost post)
|
||||
void OnManageAclRequested(object? sender, BlogPostDto post)
|
||||
{
|
||||
var app = Application.Current as App;
|
||||
var services = app?.ServiceProvider;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ public partial class PostAclDialog : ContentPage
|
|||
InitializeComponent();
|
||||
}
|
||||
|
||||
public PostAclDialog(BlogPost post, BlogAclApiClient aclClient, CircleApiClient circleClient)
|
||||
public PostAclDialog(BlogPostDto post, BlogAclApiClient aclClient, CircleApiClient circleClient)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new PostAclDialogViewModel(post, aclClient, circleClient);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ using Yavsc.Abstract.Identity.Security;
|
|||
|
||||
namespace Yavsc.Blogspot;
|
||||
|
||||
public class BlogPost : IBlogPost
|
||||
public class BlogPostDto : IBlogPost
|
||||
{
|
||||
public string AuthorId { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ namespace Yavsc.Api.Client;
|
|||
/// HTTP client for <c>/api/blogacl</c> on the Yavsc Blogs server.
|
||||
///
|
||||
/// <para>Each <see cref="CircleAuthorizationDto"/> grants a single
|
||||
/// <c>Circle</c> access to a single <c>BlogPost</c>. The server
|
||||
/// <c>Circle</c> access to a single <c>BlogPostDto</c>. The server
|
||||
/// scopes every endpoint to the caller's uid: only the author of
|
||||
/// the underlying blog post can list, create, modify, or delete
|
||||
/// its ACL entries.</para>
|
||||
|
|
|
|||
|
|
@ -53,19 +53,19 @@ public sealed class BlogApiClient
|
|||
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
|
||||
}
|
||||
|
||||
public Task<List<BlogPost>> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default)
|
||||
=> _api.CallAsync<List<BlogPost>>(
|
||||
public Task<List<BlogPostDto>> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default)
|
||||
=> _api.CallAsync<List<BlogPostDto>>(
|
||||
HttpMethod.Get,
|
||||
$"{_pathPrefix}?start={start}&take={take}",
|
||||
ct: ct);
|
||||
|
||||
public Task<BlogPost?> GetPostAsync(long id, CancellationToken ct = default)
|
||||
=> _api.CallAsync<BlogPost?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
|
||||
public Task<BlogPostDto?> GetPostAsync(long id, CancellationToken ct = default)
|
||||
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
|
||||
|
||||
public Task<BlogPost?> CreatePostAsync(BlogPost post, CancellationToken ct = default)
|
||||
=> _api.CallAsync<BlogPost?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
|
||||
public Task<BlogPostDto?> CreatePostAsync(BlogPostDto post, CancellationToken ct = default)
|
||||
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
|
||||
|
||||
public Task UpdatePostAsync(long id, BlogPost post, CancellationToken ct = default)
|
||||
public Task UpdatePostAsync(long id, BlogPostDto post, CancellationToken ct = default)
|
||||
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct);
|
||||
|
||||
public Task DeletePostAsync(long id, CancellationToken ct = default)
|
||||
|
|
|
|||
111
src/Yavsc.Blogs/Controllers/UserSearchApiController.cs
Normal file
111
src/Yavsc.Blogs/Controllers/UserSearchApiController.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Models;
|
||||
|
||||
namespace Yavsc.Blogs.Controllers
|
||||
{
|
||||
/// <summary>
|
||||
/// Central user search endpoint used by client address books
|
||||
/// (PostIt.Desktop, future PostIt.Browser CLI, etc.).
|
||||
///
|
||||
/// <para>Live in <c>Yavsc.Blogs</c> rather than <c>Yavsc.Api</c>
|
||||
/// because Yavsc.Api is not yet enabled in production; future
|
||||
/// migration is mechanical (the namespace and route prefix are
|
||||
/// the only ties to the host project).</para>
|
||||
///
|
||||
/// <para>Authorisation: any authenticated caller can search.
|
||||
/// Results include <c>Email</c> on a best-effort basis —
|
||||
/// the field is included because the address-book use case
|
||||
/// (composing a circle membership, sending an invite) needs
|
||||
/// it. The data set is the entire user table of the
|
||||
/// instance, which on Yavsc's single-tenant deployments is
|
||||
/// a closed community where users already know each other.
|
||||
/// Multi-tenant deployments should gate this controller
|
||||
/// behind a tenant-scoped authorisation policy before
|
||||
/// exposing it.</para>
|
||||
/// </summary>
|
||||
[Produces("application/json")]
|
||||
[Route("api/user-search")]
|
||||
[Authorize]
|
||||
public class UserSearchApiController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public UserSearchApiController(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search users by display name and/or email.
|
||||
/// </summary>
|
||||
/// <param name="q">Substring filter on
|
||||
/// <see cref="ApplicationUser.FullName"/> or
|
||||
/// <see cref="ApplicationUser.UserName"/> (case-insensitive,
|
||||
/// contains). Optional.</param>
|
||||
/// <param name="e">Exact filter on
|
||||
/// <see cref="ApplicationUser.Email"/> (case-insensitive
|
||||
/// equality). Optional.</param>
|
||||
/// <param name="take">Maximum number of results, capped at
|
||||
/// 100. Default 25.</param>
|
||||
// GET: api/user-search?q=foo&e=bar@example.com&take=25
|
||||
[HttpGet]
|
||||
public async Task<IEnumerable<UserSearchResultDto>> SearchAsync(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? e = null,
|
||||
[FromQuery] int take = 25)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
IQueryable<ApplicationUser> query = _context.Users;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(e))
|
||||
{
|
||||
// Email is treated as an exact match — most address
|
||||
// book callers already know the email they're
|
||||
// searching for and we don't want to surface a
|
||||
// long tail of partial matches.
|
||||
var normalised = e.Trim();
|
||||
query = query.Where(u => u.Email != null && u.Email.ToLower() == normalised.ToLower());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var needle = q.Trim();
|
||||
query = query.Where(u =>
|
||||
(u.FullName != null && u.FullName.ToLower().Contains(needle.ToLower())) ||
|
||||
(u.UserName != null && u.UserName.ToLower().Contains(needle.ToLower())));
|
||||
}
|
||||
|
||||
var results = await query
|
||||
.OrderBy(u => u.FullName ?? u.UserName)
|
||||
.Take(take)
|
||||
.Select(u => new UserSearchResultDto
|
||||
{
|
||||
Id = u.Id,
|
||||
UserName = u.UserName ?? string.Empty,
|
||||
FullName = u.FullName,
|
||||
Avatar = u.Avatar,
|
||||
Email = u.Email,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search-result shape. Flat DTO with no navigation
|
||||
/// properties so the JSON stays small even if the user
|
||||
/// table grows.
|
||||
/// </summary>
|
||||
public sealed class UserSearchResultDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string? FullName { get; set; }
|
||||
public string? Avatar { get; set; }
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue