# Web API Design ## Overview This document describes the architecture and implementation approach for the Web API layer, including ASP.NET Core 10 controllers, SignalR hub, LDAP authentication, and cookie-based session management. ## Architecture ### High-Level Component Diagram ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Blazor WebAssembly Client │ │ - HTTP API calls (fetch) │ │ - SignalR HubConnection │ └────────────────────────────────────┬────────────────────────────────────────┘ │ HTTPS ┌───────────────────────────┼───────────────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ AuthController │ │ SearchController│ │ StatusHub │ │ /api/auth/* │ │ /api/search/* │ │ /hubs/status │ │ [AllowAnonymous]│ │ [Authorize] │ │ Real-time │ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │ │ ▼ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ IAuthService │ │ ILotFinder │ │ IHubContext │ │ (LDAP/Fake) │ │ Repository │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ ``` ### Project Structure ``` NEW/src/JdeScoping.Api/ ├── Controllers/ │ ├── ApiControllerBase.cs # Base controller with CurrentUser │ ├── AuthController.cs # Authentication endpoints │ ├── SearchController.cs # Search management endpoints │ ├── LookupController.cs # Autocomplete lookup endpoints │ └── FileController.cs # File upload/download endpoints ├── Hubs/ │ └── StatusHub.cs # SignalR hub for real-time updates ├── Services/ │ ├── IAuthService.cs # Authentication service interface │ ├── LdapAuthService.cs # LDAP authentication implementation │ └── FakeAuthService.cs # Development mode auth bypass ├── Models/ │ ├── LoginRequest.cs # Login request DTO │ ├── AuthResult.cs # Authentication result record │ └── FileUploadResult.cs # File upload response DTO ├── Security/ │ ├── UserIdentity.cs # Claims-based user identity │ └── ClaimsPrincipalExtensions.cs # Extension methods for claims ├── Configuration/ │ ├── LdapOptions.cs # LDAP configuration options │ └── AuthOptions.cs # Authentication configuration ├── ServiceCollectionExtensions.cs # DI registration └── JdeScoping.Api.csproj ``` ## Controller Architecture ### ApiControllerBase Base controller providing access to current user context: ```csharp [ApiController] public abstract class ApiControllerBase : ControllerBase { /// /// Gets the current authenticated user from claims. /// Returns null if not authenticated. /// protected UserInfo? CurrentUser => User.Identity?.IsAuthenticated == true ? User.ToUserInfo() : null; /// /// Gets the current username from claims. /// protected string? CurrentUserName => User.FindFirstValue(ClaimTypes.Name); } ``` ### AuthController Authentication endpoints for Blazor WASM client: ```csharp [Route("api/auth")] [ApiController] public class AuthController : ApiControllerBase { private readonly IAuthService _authService; private readonly ILogger _logger; [HttpPost("login")] [AllowAnonymous] public async Task> Login( [FromBody] LoginRequest request, CancellationToken ct) { var result = await _authService.AuthenticateAsync( request.Username, request.Password, ct); if (!result.Success) { return Unauthorized(new { message = result.ErrorMessage }); } // Sign out existing session await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); // Create claims identity var claims = new List { new(ClaimTypes.Name, result.User!.Username), new(ClaimTypes.GivenName, result.User.FirstName), new(ClaimTypes.Surname, result.User.LastName), new(ClaimTypes.Email, result.User.EmailAddress), new("title", result.User.Title), new("dn", result.User.DN) }; var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var principal = new ClaimsPrincipal(identity); // Sign in with non-persistent cookie await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, principal, new AuthenticationProperties { IsPersistent = false }); return Ok(result.User); } [HttpPost("logout")] [Authorize] public async Task Logout() { await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return Ok(); } [HttpGet("me")] [Authorize] public ActionResult GetCurrentUser() { return Ok(CurrentUser); } } ``` ### SearchController Search management with proper authorization: ```csharp [Route("api/search")] [ApiController] [Authorize] public class SearchController : ApiControllerBase { private readonly ILotFinderRepository _repository; private readonly IHubContext _hubContext; private readonly ILogger _logger; [HttpGet] public async Task>> GetSearches( CancellationToken ct) { var searches = await _repository.GetUserSearchesAsync(CurrentUserName!, ct); return Ok(searches.OrderByDescending(s => s.StartDT)); } [HttpGet("queue")] public async Task>> GetQueuedSearches( CancellationToken ct) { var searches = await _repository.GetQueuedSearchesAsync(ct); return Ok(searches); } [HttpGet("{id:int}")] public async Task> GetSearch(int id, CancellationToken ct) { var search = await _repository.GetSearchAsync(id, ct); if (search is null) return NotFound(); return Ok(new SearchViewModel(search)); } [HttpPost("{id:int}/copy")] public async Task> CopySearch(int id, CancellationToken ct) { var original = await _repository.GetSearchAsync(id, ct); if (original is null) return NotFound(); var copy = original with { ID = 0, Status = SearchStatus.New, UserName = CurrentUserName!, SubmitDT = null, StartDT = null, EndDT = null }; var newId = await _repository.SubmitSearchAsync(copy, ct); return CreatedAtAction(nameof(GetSearch), new { id = newId }, newId); } [HttpPost] public async Task> CreateSearch( [FromBody] SearchViewModel viewModel, CancellationToken ct) { var search = viewModel.ToEntity(); search.UserName = CurrentUserName!; var searchId = await _repository.SubmitSearchAsync(search, ct); // Publish to SignalR (best-effort, swallow exceptions) try { var searchUpdate = new SearchUpdate { ID = searchId, UserName = CurrentUserName!, Name = search.Name, Status = search.Status, Timestamp = DateTime.UtcNow }; await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate, ct); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to publish search update to SignalR"); } return CreatedAtAction(nameof(GetSearch), new { id = searchId }, searchId); } [HttpGet("{id:int}/results")] public async Task GetResults(int id, CancellationToken ct) { var data = await _repository.GetSearchResultsAsync(id, ct); if (data is null || data.Length == 0) return NotFound(); return File( data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "search_results.xlsx"); } } ``` ### LookupController Public autocomplete endpoints (no authorization): ```csharp [Route("api/lookup")] [ApiController] public class LookupController : ApiControllerBase { private readonly ILotFinderRepository _repository; [HttpGet("items")] public async Task>> FindItems( [FromQuery] string q, CancellationToken ct) { var items = await _repository.SearchItemsAsync(q, ct); return Ok(items.OrderBy(i => i.ItemNumber).Select(i => i.ToViewModel())); } [HttpGet("profit-centers")] public async Task>> FindProfitCenters( [FromQuery] string q, CancellationToken ct) { var centers = await _repository.SearchProfitCentersAsync(q, ct); return Ok(centers.OrderBy(pc => pc.Code).Select(pc => pc.ToViewModel())); } [HttpGet("work-centers")] public async Task>> FindWorkCenters( [FromQuery] string q, CancellationToken ct) { var centers = await _repository.SearchWorkCentersAsync(q, ct); return Ok(centers.OrderBy(wc => wc.Code).Select(wc => wc.ToViewModel())); } [HttpGet("operators")] public async Task>> FindOperators( [FromQuery] string q, CancellationToken ct) { var users = await _repository.SearchUsersAsync(q, ct); return Ok(users.OrderBy(u => u.FullName).Select(u => u.ToViewModel())); } } ``` ### FileController Excel file upload/download with caching: ```csharp [Route("api/file")] [ApiController] public class FileController : ApiControllerBase { private const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private readonly ILotFinderRepository _repository; private readonly IMemoryCache _cache; private readonly ILogger _logger; [HttpPost("work-orders/upload")] public async Task>> UploadWorkOrders( IFormFile file, CancellationToken ct) { if (file is null) return Ok(new FileUploadResult { WasSuccessful = false, ErrorMessage = "No file uploaded" }); try { using var stream = file.OpenReadStream(); using var workbook = new XLWorkbook(stream); var worksheet = workbook.Worksheet(1); var workOrderNumbers = new List(); var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1; for (int row = 2; row <= lastRow; row++) { var cellValue = worksheet.Cell(row, 1).GetString()?.Trim(); if (long.TryParse(cellValue, out var woNumber)) { workOrderNumbers.Add(woNumber); } } var workOrders = await _repository.LookupWorkOrdersAsync(workOrderNumbers, ct); var viewModels = workOrders .Select(wo => wo.ToViewModel()) .DistinctBy(wo => new { wo.WorkOrderNumber, wo.ItemNumber }) .OrderBy(wo => wo.WorkOrderNumber) .ToArray(); return Ok(new FileUploadResult { WasSuccessful = true, Data = viewModels }); } catch (Exception ex) { _logger.LogError(ex, "Failed to parse uploaded work order file"); return Ok(new FileUploadResult { WasSuccessful = false, ErrorMessage = "Failed to parse uploaded file" }); } } [HttpPost("work-orders/template")] public ActionResult GenerateWorkOrderTemplate([FromBody] List workOrders) { var data = ExcelTemplateGenerator.Generate(workOrders ?? [], "Work Order Number"); var key = CacheData(data); return Ok(key); } [HttpGet("work-orders/template/{key}")] public IActionResult DownloadWorkOrderTemplate(string key) { return DownloadCachedFile(key, "work_order_template.xlsx"); } // Similar methods for part-numbers, component-lots, part-operations... private string CacheData(byte[] data) { var key = Guid.NewGuid().ToString("N").ToUpperInvariant(); _cache.Set(key, data, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1) }); return key; } private IActionResult DownloadCachedFile(string key, string fileName) { if (!_cache.TryGetValue(key, out byte[]? data) || data is null) { return NotFound("Cached file not found or expired"); } _cache.Remove(key); return File(data, ContentType, fileName); } } ``` ## SignalR StatusHub Real-time updates using ASP.NET Core SignalR: ```csharp public class StatusHub : Hub { private static StatusUpdate _cachedStatus = new() { Message = "Unknown", Timestamp = DateTime.UtcNow }; private readonly ILogger _logger; public StatusHub(ILogger logger) { _logger = logger; } /// /// Called by worker service to update status. /// Caches the update and broadcasts to all clients. /// public async Task SetStatus(StatusUpdate statusUpdate) { _cachedStatus = statusUpdate; await Clients.All.SendAsync("statusUpdate", statusUpdate); } /// /// Called by clients to get initial cached status on connection. /// public StatusUpdate GetCachedStatus() { return _cachedStatus; } /// /// Called by controllers/services to broadcast search updates. /// public async Task PublishSearchUpdate(SearchUpdate searchUpdate) { await Clients.All.SendAsync("searchUpdate", searchUpdate); } public override Task OnConnectedAsync() { _logger.LogInformation("Client {ConnectionId} connected to StatusHub", Context.ConnectionId); return base.OnConnectedAsync(); } public override Task OnDisconnectedAsync(Exception? exception) { _logger.LogInformation("Client {ConnectionId} disconnected from StatusHub", Context.ConnectionId); return base.OnDisconnectedAsync(exception); } } ``` ### Publishing from Controllers/Services Use `IHubContext` for publishing from outside the hub: ```csharp // Inject IHubContext into controller private readonly IHubContext _hubContext; // Publish status update await _hubContext.Clients.All.SendAsync("statusUpdate", statusUpdate); // Publish search update await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate); ``` ## LDAP Authentication ### IAuthService Interface ```csharp public interface IAuthService { Task AuthenticateAsync( string username, string password, CancellationToken ct = default); Task GetUserInfoAsync( string username, CancellationToken ct = default); } public record AuthResult( bool Success, UserInfo? User, string? ErrorMessage); ``` ### LdapAuthService Implementation Using `System.DirectoryServices.Protocols` for cross-platform compatibility: ```csharp public sealed class LdapAuthService : IAuthService { private const string LdapLookupFormat = "(sAMAccountName={0})"; private readonly LdapOptions _options; private readonly ILogger _logger; public LdapAuthService( IOptions options, ILogger logger) { _options = options.Value; _logger = logger; } public async Task AuthenticateAsync( string username, string password, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { return new AuthResult(false, null, "Username and password are required"); } // Try each configured LDAP server string? lastError = null; foreach (var serverUrl in _options.ServerUrls) { try { ct.ThrowIfCancellationRequested(); // Attempt authentication if (!await TryBindAsync(serverUrl, username, password, ct)) { return new AuthResult(false, null, "Incorrect username or password"); } // Verify group membership if (!string.IsNullOrEmpty(_options.GroupDn)) { if (!await IsInGroupAsync(serverUrl, username, password, _options.GroupDn, ct)) { return new AuthResult(false, null, "User is not a member of the required security group"); } } // Lookup user info var userInfo = await LookupUserAsync(serverUrl, username, password, ct); if (userInfo is null) { return new AuthResult(false, null, "Failed to retrieve user information"); } return new AuthResult(true, userInfo, null); } catch (LdapException ex) { _logger.LogWarning(ex, "LDAP authentication failed for server {Server}", serverUrl); lastError = ex.Message; continue; // Try next server } } return new AuthResult(false, null, lastError ?? "Unable to connect to directory server"); } private async Task TryBindAsync( string serverUrl, string username, string password, CancellationToken ct) { using var connection = CreateConnection(serverUrl); var credential = new NetworkCredential(username, password); connection.Credential = credential; connection.AuthType = AuthType.Negotiate; try { await Task.Run(() => connection.Bind(), ct); return true; } catch (LdapException ex) when (ex.ErrorCode == 49) // Invalid credentials { return false; } } private async Task IsInGroupAsync( string serverUrl, string username, string password, string groupDn, CancellationToken ct) { using var connection = CreateConnection(serverUrl); connection.Credential = new NetworkCredential(username, password); connection.AuthType = AuthType.Negotiate; await Task.Run(() => connection.Bind(), ct); var searchRequest = new SearchRequest( _options.SearchBase, string.Format(LdapLookupFormat, username), SearchScope.Subtree, "memberOf"); var response = (SearchResponse)await Task.Run( () => connection.SendRequest(searchRequest), ct); foreach (SearchResultEntry entry in response.Entries) { var memberOf = entry.Attributes["memberOf"]; if (memberOf != null) { foreach (var group in memberOf.GetValues(typeof(string))) { if (string.Equals((string)group, groupDn, StringComparison.OrdinalIgnoreCase)) { return true; } } } } return false; } private async Task LookupUserAsync( string serverUrl, string username, string password, CancellationToken ct) { using var connection = CreateConnection(serverUrl); connection.Credential = new NetworkCredential(username, password); connection.AuthType = AuthType.Negotiate; await Task.Run(() => connection.Bind(), ct); var searchRequest = new SearchRequest( _options.SearchBase, string.Format(LdapLookupFormat, username), SearchScope.Subtree, "distinguishedName", "givenName", "sn", "mail", "title"); var response = (SearchResponse)await Task.Run( () => connection.SendRequest(searchRequest), ct); if (response.Entries.Count == 0) return null; var entry = response.Entries[0]; return new UserInfo { DN = GetAttribute(entry, "distinguishedName"), Username = username.ToLowerInvariant(), FirstName = GetAttribute(entry, "givenName"), LastName = GetAttribute(entry, "sn"), EmailAddress = GetAttribute(entry, "mail"), Title = GetAttribute(entry, "title") }; } private LdapConnection CreateConnection(string serverUrl) { var connection = new LdapConnection(serverUrl); connection.SessionOptions.ProtocolVersion = 3; connection.SessionOptions.SecureSocketLayer = false; connection.Timeout = TimeSpan.FromSeconds(_options.ConnectionTimeoutSeconds); return connection; } private static string GetAttribute(SearchResultEntry entry, string name) { var attr = entry.Attributes[name]; return attr?.Count > 0 ? (string)attr[0] : string.Empty; } public Task GetUserInfoAsync(string username, CancellationToken ct = default) { // Not implemented for LDAP - user info is only available during authentication throw new NotSupportedException("GetUserInfoAsync requires password for LDAP lookup"); } } ``` ### FakeAuthService for Development ```csharp public sealed class FakeAuthService : IAuthService { public Task AuthenticateAsync( string username, string password, CancellationToken ct = default) { // Accept any credentials in development mode var user = new UserInfo { DN = $"CN={username},OU=Users,DC=example,DC=com", Username = username.ToLowerInvariant(), FirstName = "Dev", LastName = "User", EmailAddress = $"{username}@example.com", Title = "Developer" }; return Task.FromResult(new AuthResult(true, user, null)); } public Task GetUserInfoAsync(string username, CancellationToken ct = default) { var user = new UserInfo { DN = $"CN={username},OU=Users,DC=example,DC=com", Username = username.ToLowerInvariant(), FirstName = "Dev", LastName = "User", EmailAddress = $"{username}@example.com", Title = "Developer" }; return Task.FromResult(user); } } ``` ## Configuration Options ### LdapOptions ```csharp public class LdapOptions { public const string SectionName = "Ldap"; /// /// LDAP server URLs (supports multiple for failover). /// Example: ["ldap.corp.example.com", "ldap2.corp.example.com"] /// public string[] ServerUrls { get; set; } = []; /// /// Distinguished name of required group for access. /// Example: "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com" /// public string GroupDn { get; set; } = string.Empty; /// /// LDAP search base for user lookups. /// Example: "DC=corp,DC=example,DC=com" /// public string SearchBase { get; set; } = string.Empty; /// /// Connection timeout in seconds. /// public int ConnectionTimeoutSeconds { get; set; } = 30; } ``` ### AuthOptions ```csharp public class AuthOptions { public const string SectionName = "Auth"; /// /// Enable fake authentication for development. /// When true, any credentials are accepted. /// public bool UseFakeAuth { get; set; } = false; /// /// Name of the authentication cookie. /// public string CookieName { get; set; } = "ScopingTool.Auth"; /// /// Cookie expiration in minutes (default: 8 hours). /// public int CookieExpirationMinutes { get; set; } = 480; /// /// Optional list of usernames that bypass group check. /// Use sparingly for admin/testing purposes. /// public string[] AdminBypassUsers { get; set; } = []; } ``` ### Configuration Example (appsettings.json) ```json { "Auth": { "UseFakeAuth": false, "CookieName": "ScopingTool.Auth", "CookieExpirationMinutes": 480, "AdminBypassUsers": [] }, "Ldap": { "ServerUrls": ["ldap.corp.example.com"], "GroupDn": "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com", "SearchBase": "DC=corp,DC=example,DC=com", "ConnectionTimeoutSeconds": 30 } } ``` ### Development Configuration (appsettings.Development.json) ```json { "Auth": { "UseFakeAuth": true } } ``` ## Cookie Authentication Configuration ### Program.cs Setup ```csharp // Configure authentication builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { var authOptions = builder.Configuration .GetSection(AuthOptions.SectionName) .Get() ?? new AuthOptions(); options.Cookie.Name = authOptions.CookieName; options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; options.Cookie.SameSite = SameSiteMode.Lax; options.ExpireTimeSpan = TimeSpan.FromMinutes(authOptions.CookieExpirationMinutes); options.SlidingExpiration = true; // Return 401 instead of redirect for API requests (Blazor WASM) options.Events.OnRedirectToLogin = context => { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; }; options.Events.OnRedirectToAccessDenied = context => { context.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; }; }); builder.Services.AddAuthorization(); ``` ## Service Registration ### AddWebApi Extension Method ```csharp public static class ServiceCollectionExtensions { public static IServiceCollection AddWebApi( this IServiceCollection services, IConfiguration configuration) { // Bind configuration options services.Configure(configuration.GetSection(LdapOptions.SectionName)); services.Configure(configuration.GetSection(AuthOptions.SectionName)); // Register auth service based on configuration var authOptions = configuration .GetSection(AuthOptions.SectionName) .Get() ?? new AuthOptions(); if (authOptions.UseFakeAuth) { services.AddSingleton(); } else { services.AddScoped(); } // Register memory cache for file downloads services.AddMemoryCache(); // Configure SignalR services.AddSignalR(); // Configure controllers services.AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); // Configure Swagger/OpenAPI services.AddEndpointsApiExplorer(); services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "JDE Scoping Tool API", Version = "v1" }); }); return services; } public static WebApplication UseWebApi(this WebApplication app) { // Use Swagger in development if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapHub("/hubs/status"); return app; } } ``` ## Data Models ### UserInfo (renamed from LDAPEntry) ```csharp public class UserInfo { public string DN { get; set; } = string.Empty; public string Username { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public string DisplayName => string.IsNullOrWhiteSpace(FirstName) && string.IsNullOrWhiteSpace(LastName) ? Username : $"{FirstName} {LastName}".Trim(); public string Title { get; set; } = string.Empty; public string EmailAddress { get; set; } = string.Empty; } ``` ### StatusUpdate ```csharp public class StatusUpdate { public string Message { get; set; } = string.Empty; public DateTime Timestamp { get; set; } } ``` ### SearchUpdate ```csharp public class SearchUpdate { public int ID { get; set; } public string UserName { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; [JsonConverter(typeof(JsonStringEnumConverter))] public SearchStatus Status { get; set; } public DateTime? SubmitDT { get; set; } public DateTime? StartDT { get; set; } public DateTime? EndDT { get; set; } public DateTime Timestamp { get; set; } } ``` ### LoginRequest ```csharp public class LoginRequest { [Required] public string Username { get; set; } = string.Empty; [Required] public string Password { get; set; } = string.Empty; } ``` ### FileUploadResult ```csharp public class FileUploadResult { public bool WasSuccessful { get; set; } public string? ErrorMessage { get; set; } public T[]? Data { get; set; } } ``` ## Testing Strategy ### Unit Tests - **Controller Tests**: Test action methods with mocked dependencies - **Auth Service Tests**: Test LDAP connection handling (integration tests) - **File Controller Tests**: Test upload parsing and caching - **SignalR Hub Tests**: Test hub method invocation ### Mock Patterns ```csharp // Controller test with NSubstitute [Fact] public async Task GetSearches_ReturnsUserSearches_OrderedByStartDtDescending() { // Arrange var repository = Substitute.For(); var hubContext = Substitute.For>(); var logger = Substitute.For>(); var searches = new[] { new Search { ID = 1, StartDT = DateTime.Now.AddHours(-2) }, new Search { ID = 2, StartDT = DateTime.Now } }; repository.GetUserSearchesAsync("testuser", Arg.Any()) .Returns(searches); var controller = new SearchController(repository, hubContext, logger); controller.ControllerContext = CreateControllerContext("testuser"); // Act var result = await controller.GetSearches(CancellationToken.None); // Assert var okResult = result.Result.ShouldBeOfType(); var viewModels = okResult.Value.ShouldBeOfType(); viewModels[0].ID.ShouldBe(2); // Most recent first viewModels[1].ID.ShouldBe(1); } ``` ## NuGet Dependencies ```xml ```