Initial commit: JDE Scoping Tool migration project

Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
This commit is contained in:
Joseph Doherty
2026-01-02 07:43:29 -05:00
commit 26ff8d9b4f
1761 changed files with 596509 additions and 0 deletions
@@ -0,0 +1,189 @@
using System.Security.Claims;
using JdeScoping.Api.Controllers;
using JdeScoping.Api.Models;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
namespace JdeScoping.Api.Tests.Controllers;
public class AuthControllerTests
{
private readonly IAuthService _authService;
private readonly ILogger<AuthController> _logger;
private readonly AuthController _controller;
public AuthControllerTests()
{
_authService = Substitute.For<IAuthService>();
_logger = Substitute.For<ILogger<AuthController>>();
_controller = new AuthController(_authService, _logger);
}
[Fact]
public async Task Login_WithValidCredentials_ReturnsUserInfo()
{
// Arrange
var request = new LoginRequest { Username = "testuser", Password = "password123" };
var user = new UserInfo
{
Dn = "CN=testuser,DC=example,DC=com",
Username = "testuser",
FirstName = "Test",
LastName = "User",
EmailAddress = "test@example.com",
Title = "Developer"
};
_authService.AuthenticateAsync("testuser", "password123", Arg.Any<CancellationToken>())
.Returns(new AuthResult(true, user, null));
// Setup HttpContext with mock authentication
var httpContext = CreateMockHttpContext();
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
// Act
var result = await _controller.Login(request, CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var returnedUser = okResult.Value.ShouldBeOfType<UserInfo>();
returnedUser.Username.ShouldBe("testuser");
returnedUser.FirstName.ShouldBe("Test");
}
[Fact]
public async Task Login_WithInvalidCredentials_Returns401()
{
// Arrange
var request = new LoginRequest { Username = "testuser", Password = "wrongpassword" };
_authService.AuthenticateAsync("testuser", "wrongpassword", Arg.Any<CancellationToken>())
.Returns(new AuthResult(false, null, "Incorrect username or password"));
// Setup HttpContext
var httpContext = CreateMockHttpContext();
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
// Act
var result = await _controller.Login(request, CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<UnauthorizedObjectResult>();
}
[Fact]
public async Task Logout_ClearsAuthentication()
{
// Arrange
var httpContext = CreateAuthenticatedHttpContext("testuser");
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
// Act
var result = await _controller.Logout();
// Assert
result.ShouldBeOfType<OkResult>();
}
[Fact]
public void GetCurrentUser_WhenAuthenticated_ReturnsUserInfo()
{
// Arrange
var claims = new List<Claim>
{
new(ClaimTypes.Name, "testuser"),
new(ClaimTypes.GivenName, "Test"),
new(ClaimTypes.Surname, "User"),
new(ClaimTypes.Email, "test@example.com"),
new("title", "Developer"),
new("dn", "CN=testuser,DC=example,DC=com")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext { User = principal };
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
// Act
var result = _controller.GetCurrentUser();
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var user = okResult.Value.ShouldBeOfType<UserInfo>();
user.Username.ShouldBe("testuser");
}
[Fact]
public void GetCurrentUser_ExtractsAllClaimsCorrectly()
{
// Arrange
var claims = new List<Claim>
{
new(ClaimTypes.Name, "jsmith"),
new(ClaimTypes.GivenName, "John"),
new(ClaimTypes.Surname, "Smith"),
new(ClaimTypes.Email, "jsmith@example.com"),
new("title", "Senior Engineer"),
new("dn", "CN=jsmith,OU=Users,DC=example,DC=com")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext { User = principal };
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
// Act
var result = _controller.GetCurrentUser();
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var user = okResult.Value.ShouldBeOfType<UserInfo>();
user.Username.ShouldBe("jsmith");
user.FirstName.ShouldBe("John");
user.LastName.ShouldBe("Smith");
user.EmailAddress.ShouldBe("jsmith@example.com");
user.Title.ShouldBe("Senior Engineer");
user.Dn.ShouldBe("CN=jsmith,OU=Users,DC=example,DC=com");
user.DisplayName.ShouldBe("John Smith");
}
private static HttpContext CreateMockHttpContext()
{
var authServiceMock = Substitute.For<IAuthenticationService>();
authServiceMock.SignOutAsync(Arg.Any<HttpContext>(), Arg.Any<string>(), Arg.Any<AuthenticationProperties>())
.Returns(Task.CompletedTask);
authServiceMock.SignInAsync(Arg.Any<HttpContext>(), Arg.Any<string>(), Arg.Any<ClaimsPrincipal>(), Arg.Any<AuthenticationProperties>())
.Returns(Task.CompletedTask);
var serviceProvider = Substitute.For<IServiceProvider>();
serviceProvider.GetService(typeof(IAuthenticationService)).Returns(authServiceMock);
var httpContext = new DefaultHttpContext
{
RequestServices = serviceProvider
};
return httpContext;
}
private static HttpContext CreateAuthenticatedHttpContext(string username)
{
var httpContext = CreateMockHttpContext();
var claims = new List<Claim>
{
new(ClaimTypes.Name, username),
new("dn", $"CN={username},DC=example,DC=com")
};
var identity = new ClaimsIdentity(claims, "Test");
httpContext.User = new ClaimsPrincipal(identity);
return httpContext;
}
}
@@ -0,0 +1,237 @@
using JdeScoping.Api.Controllers;
using JdeScoping.Api.Models;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.Core.Models.WorkOrders;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
namespace JdeScoping.Api.Tests.Controllers;
public class FileControllerTests
{
private readonly ILotFinderRepository _repository;
private readonly IExcelParserService _parserService;
private readonly IExcelTemplateService _templateService;
private readonly ILogger<FileIOController> _logger;
private readonly FileIOController _controller;
public FileControllerTests()
{
_repository = Substitute.For<ILotFinderRepository>();
_parserService = Substitute.For<IExcelParserService>();
_templateService = Substitute.For<IExcelTemplateService>();
_logger = Substitute.For<ILogger<FileIOController>>();
_controller = new FileIOController(_repository, _parserService, _templateService, _logger);
}
[Fact]
public async Task UploadWorkOrders_CallsParserAndRepository()
{
// Arrange
var formFile = CreateFormFile(new byte[] { 1, 2, 3 }, "workorders.xlsx");
var parsedNumbers = new List<long> { 12345, 67890 };
_parserService.ParseWorkOrders(Arg.Any<Stream>()).Returns(parsedNumbers);
var workOrders = new List<WorkOrder>
{
new() { WorkOrderNumber = 12345, ItemNumber = "ITEM-001" },
new() { WorkOrderNumber = 67890, ItemNumber = "ITEM-002" }
};
_repository.LookupWorkordersAsync(parsedNumbers, Arg.Any<CancellationToken>())
.Returns(workOrders);
// Act
var result = await _controller.UploadWorkOrders(formFile, CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var uploadResult = okResult.Value.ShouldBeOfType<FileUploadResult<WorkOrderViewModel>>();
uploadResult.WasSuccessful.ShouldBeTrue();
uploadResult.Data.ShouldNotBeNull();
uploadResult.Data.Length.ShouldBe(2);
}
[Fact]
public void DownloadWorkOrders_CallsTemplateService()
{
// Arrange
var workOrders = new List<long> { 12345, 67890 };
var expectedBytes = new byte[] { 1, 2, 3, 4, 5 };
_templateService.GenerateSingleColumn(workOrders, "Work Order Number").Returns(expectedBytes);
// Act
var result = _controller.DownloadWorkOrders(workOrders);
// Assert
result.ShouldBeOfType<FileContentResult>();
var fileResult = (FileContentResult)result;
fileResult.ContentType.ShouldBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
fileResult.FileDownloadName.ShouldBe("work_order_template.xlsx");
fileResult.FileContents.ShouldBe(expectedBytes);
}
[Fact]
public async Task UploadWorkOrders_NoFile_ReturnsError()
{
// Act
var result = await _controller.UploadWorkOrders(null, CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var uploadResult = okResult.Value.ShouldBeOfType<FileUploadResult<WorkOrderViewModel>>();
uploadResult.WasSuccessful.ShouldBeFalse();
uploadResult.ErrorMessage.ShouldBe("No file uploaded");
}
[Fact]
public void UploadPartOperations_CallsParser()
{
// Arrange
var formFile = CreateFormFile(new byte[] { 1, 2, 3 }, "partops.xlsx");
var parsedOps = new List<PartOperationViewModel>
{
new() { ItemNumber = "ITEM-001", OperationNumber = "100", MisNumber = "MIS001", MisRevision = "A" }
};
_parserService.ParsePartOperations(Arg.Any<Stream>()).Returns(parsedOps);
// Act
var result = _controller.UploadPartOperations(formFile);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var uploadResult = okResult.Value.ShouldBeOfType<FileUploadResult<PartOperationViewModel>>();
uploadResult.WasSuccessful.ShouldBeTrue();
uploadResult.Data.ShouldNotBeNull();
uploadResult.Data.Length.ShouldBe(1);
}
[Fact]
public async Task UploadComponentLots_ParsesTwoColumnsAndLooksUpLots()
{
// Arrange
var formFile = CreateFormFile(new byte[] { 1, 2, 3 }, "componentlots.xlsx");
var parsedLots = new List<LotViewModel>
{
new() { LotNumber = "LOT001", ItemNumber = "ITEM-001" },
new() { LotNumber = "LOT002", ItemNumber = "ITEM-002" }
};
_parserService.ParseComponentLots(Arg.Any<Stream>()).Returns(parsedLots);
var lots = new List<Lot>
{
new() { LotNumber = "LOT001", ItemNumber = "ITEM-001" },
new() { LotNumber = "LOT002", ItemNumber = "ITEM-002" }
};
_repository.LookupLotsAsync(Arg.Any<List<LotViewModel>>(), Arg.Any<CancellationToken>())
.Returns(lots);
// Act
var result = await _controller.UploadComponentLots(formFile, CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var uploadResult = okResult.Value.ShouldBeOfType<FileUploadResult<LotViewModel>>();
uploadResult.WasSuccessful.ShouldBeTrue();
uploadResult.Data.ShouldNotBeNull();
uploadResult.Data.Length.ShouldBe(2);
}
[Fact]
public void DownloadComponentLots_CallsTemplateService()
{
// Arrange
var lots = new List<LotViewModel>
{
new() { LotNumber = "LOT001", ItemNumber = "ITEM-001" },
new() { LotNumber = "LOT002", ItemNumber = "ITEM-002" }
};
var expectedBytes = new byte[] { 1, 2, 3, 4, 5 };
_templateService.GenerateMultiColumn(
Arg.Any<object?[][]>(),
Arg.Is<string[]>(h => h.Contains("Component Lot Number") && h.Contains("Component Item Number")))
.Returns(expectedBytes);
// Act
var result = _controller.DownloadComponentLots(lots);
// Assert
result.ShouldBeOfType<FileContentResult>();
var fileResult = (FileContentResult)result;
fileResult.ContentType.ShouldBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
fileResult.FileDownloadName.ShouldBe("component_lot_template.xlsx");
fileResult.FileContents.ShouldBe(expectedBytes);
}
[Fact]
public async Task UploadItems_CallsParserAndRepository()
{
// Arrange
var formFile = CreateFormFile(new byte[] { 1, 2, 3 }, "items.xlsx");
var parsedNumbers = new List<string> { "ITEM-001", "ITEM-002" };
_parserService.ParseItems(Arg.Any<Stream>()).Returns(parsedNumbers);
var items = new List<Item>
{
new() { ItemNumber = "ITEM-001", Description = "Item 1" },
new() { ItemNumber = "ITEM-002", Description = "Item 2" }
};
_repository.LookupItemsAsync(parsedNumbers, Arg.Any<CancellationToken>())
.Returns(items);
// Act
var result = await _controller.UploadItems(formFile, CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var uploadResult = okResult.Value.ShouldBeOfType<FileUploadResult<ItemViewModel>>();
uploadResult.WasSuccessful.ShouldBeTrue();
uploadResult.Data.ShouldNotBeNull();
uploadResult.Data.Length.ShouldBe(2);
}
[Fact]
public void DownloadItems_CallsTemplateService()
{
// Arrange
var items = new List<ItemViewModel>
{
new() { ItemNumber = "ITEM-001", Description = "Item 1" },
new() { ItemNumber = "ITEM-002", Description = "Item 2" }
};
var expectedBytes = new byte[] { 1, 2, 3, 4, 5 };
_templateService.GenerateMultiColumn(
Arg.Any<object?[][]>(),
Arg.Is<string[]>(h => h.Contains("Item Number")))
.Returns(expectedBytes);
// Act
var result = _controller.DownloadItems(items);
// Assert
result.ShouldBeOfType<FileContentResult>();
var fileResult = (FileContentResult)result;
fileResult.ContentType.ShouldBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
fileResult.FileDownloadName.ShouldBe("item_number_template.xlsx");
fileResult.FileContents.ShouldBe(expectedBytes);
}
private static IFormFile CreateFormFile(byte[] content, string fileName)
{
var stream = new MemoryStream(content);
var formFile = Substitute.For<IFormFile>();
formFile.OpenReadStream().Returns(stream);
formFile.FileName.Returns(fileName);
formFile.Length.Returns(content.Length);
return formFile;
}
}
@@ -0,0 +1,134 @@
using JdeScoping.Api.Controllers;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.Core.Models.Organization;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using Shouldly;
namespace JdeScoping.Api.Tests.Controllers;
public class LookupControllerTests
{
private readonly ILotFinderRepository _repository;
private readonly LookupController _controller;
public LookupControllerTests()
{
_repository = Substitute.For<ILotFinderRepository>();
_controller = new LookupController(_repository);
}
[Fact]
public async Task FindItems_ReturnsOrderedResults()
{
// Arrange
var items = new List<Item>
{
new Item { ItemNumber = "ITEM-002", Description = "Second Item" },
new Item { ItemNumber = "ITEM-001", Description = "First Item" }
};
_repository.SearchItemsAsync("ITEM", Arg.Any<CancellationToken>())
.Returns(items);
// Act
var result = await _controller.FindItems("ITEM", CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var viewModels = okResult.Value.ShouldBeAssignableTo<IEnumerable<ItemViewModel>>()!.ToList();
viewModels.Count.ShouldBe(2);
viewModels[0].ItemNumber.ShouldBe("ITEM-001"); // Ordered by ItemNumber
viewModels[1].ItemNumber.ShouldBe("ITEM-002");
}
[Fact]
public async Task FindProfitCenters_ReturnsOrderedResults()
{
// Arrange
var centers = new List<ProfitCenter>
{
new ProfitCenter { Code = "PC2", Description = "Center 2" },
new ProfitCenter { Code = "PC1", Description = "Center 1" }
};
_repository.SearchProfitCentersAsync("PC", Arg.Any<CancellationToken>())
.Returns(centers);
// Act
var result = await _controller.FindProfitCenters("PC", CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var viewModels = okResult.Value.ShouldBeAssignableTo<IEnumerable<ProfitCenterViewModel>>()!.ToList();
viewModels.Count.ShouldBe(2);
viewModels[0].Code.ShouldBe("PC1"); // Ordered by Code
viewModels[1].Code.ShouldBe("PC2");
}
[Fact]
public async Task FindWorkCenters_ReturnsOrderedResults()
{
// Arrange
var centers = new List<WorkCenter>
{
new WorkCenter { Code = "WC2", Description = "Center 2" },
new WorkCenter { Code = "WC1", Description = "Center 1" }
};
_repository.SearchWorkCentersAsync("WC", Arg.Any<CancellationToken>())
.Returns(centers);
// Act
var result = await _controller.FindWorkCenters("WC", CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var viewModels = okResult.Value.ShouldBeAssignableTo<IEnumerable<WorkCenterViewModel>>()!.ToList();
viewModels.Count.ShouldBe(2);
viewModels[0].Code.ShouldBe("WC1"); // Ordered by Code
viewModels[1].Code.ShouldBe("WC2");
}
[Fact]
public async Task FindOperators_ReturnsOrderedResults()
{
// Arrange
var users = new List<JdeUser>
{
new JdeUser { AddressNumber = 1, UserId = "user1", FullName = "Zebra, Alice" },
new JdeUser { AddressNumber = 2, UserId = "user2", FullName = "Adams, Bob" }
};
_repository.SearchUsersAsync("user", Arg.Any<CancellationToken>())
.Returns(users);
// Act
var result = await _controller.FindOperators("user", CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var viewModels = okResult.Value.ShouldBeAssignableTo<IEnumerable<JdeUserViewModel>>()!.ToList();
viewModels.Count.ShouldBe(2);
viewModels[0].FullName.ShouldBe("Adams, Bob"); // Ordered by FullName
viewModels[1].FullName.ShouldBe("Zebra, Alice");
}
[Fact]
public async Task FindItems_WithNullQuery_PassesEmptyString()
{
// Arrange
_repository.SearchItemsAsync(string.Empty, Arg.Any<CancellationToken>())
.Returns(new List<Item>());
// Act
var result = await _controller.FindItems(null!, CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
await _repository.Received(1).SearchItemsAsync(string.Empty, Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,194 @@
using System.Security.Claims;
using JdeScoping.Api.Controllers;
using JdeScoping.Api.Hubs;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Search;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
namespace JdeScoping.Api.Tests.Controllers;
public class SearchControllerTests
{
private readonly ILotFinderRepository _repository;
private readonly IHubContext<StatusHub> _hubContext;
private readonly ILogger<SearchController> _logger;
private readonly SearchController _controller;
public SearchControllerTests()
{
_repository = Substitute.For<ILotFinderRepository>();
_hubContext = Substitute.For<IHubContext<StatusHub>>();
_logger = Substitute.For<ILogger<SearchController>>();
_controller = new SearchController(_repository, _hubContext, _logger);
SetupAuthenticatedUser("testuser");
}
[Fact]
public async Task GetSearches_ReturnsUserSearches_OrderedByStartDtDescending()
{
// Arrange
var searches = new List<Search>
{
new Search { Id = 1, Name = "Search 1", UserName = "testuser", StartDt = DateTime.Now.AddHours(-2) },
new Search { Id = 2, Name = "Search 2", UserName = "testuser", StartDt = DateTime.Now }
};
_repository.GetUserSearchesAsync("testuser", Arg.Any<CancellationToken>())
.Returns(searches);
// Act
var result = await _controller.GetSearches(CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var viewModels = okResult.Value.ShouldBeAssignableTo<IEnumerable<SearchViewModel>>()!.ToList();
viewModels.Count.ShouldBe(2);
viewModels[0].Id.ShouldBe(2); // Most recent first
viewModels[1].Id.ShouldBe(1);
}
[Fact]
public async Task CreateSearch_SavesAndPublishesToSignalR()
{
// Arrange
var viewModel = new SearchViewModel
{
Name = "New Search",
Status = SearchStatus.New,
Criteria = new SearchCriteria()
};
_repository.SubmitSearchAsync(Arg.Any<Search>(), Arg.Any<CancellationToken>())
.Returns(42);
var clientProxy = Substitute.For<IClientProxy>();
var hubClients = Substitute.For<IHubClients>();
hubClients.All.Returns(clientProxy);
_hubContext.Clients.Returns(hubClients);
// Act
var result = await _controller.CreateSearch(viewModel, CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<CreatedAtActionResult>();
var createdResult = (CreatedAtActionResult)result.Result!;
createdResult.Value.ShouldBe(42);
await _repository.Received(1).SubmitSearchAsync(
Arg.Is<Search>(s => s.Name == "New Search" && s.UserName == "testuser"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task CopySearch_ResetsStatusAndTimestamps()
{
// Arrange
var original = new Search
{
Id = 1,
Name = "Original Search",
UserName = "otheruser",
Status = SearchStatus.Ended,
SubmitDt = DateTime.Now.AddHours(-2),
StartDt = DateTime.Now.AddHours(-2),
EndDt = DateTime.Now.AddHours(-1),
CriteriaJson = "{}"
};
_repository.GetSearchAsync(1, Arg.Any<CancellationToken>())
.Returns(original);
_repository.SubmitSearchAsync(Arg.Any<Search>(), Arg.Any<CancellationToken>())
.Returns(42);
// Act
var result = await _controller.CopySearch(1, CancellationToken.None);
// Assert - CopySearch returns Ok with a copy, doesn't persist
var okResult = result.Result.ShouldBeOfType<OkObjectResult>();
var viewModel = okResult.Value.ShouldBeOfType<SearchViewModel>();
viewModel.Status.ShouldBe(SearchStatus.New);
viewModel.UserName.ShouldBe("testuser");
viewModel.Name.ShouldBe("Original Search");
viewModel.SubmitDt.ShouldBeNull();
viewModel.StartDt.ShouldBeNull();
viewModel.EndDt.ShouldBeNull();
// CopySearch does NOT persist - it just returns a copy
await _repository.DidNotReceive().SubmitSearchAsync(
Arg.Is<Search>(s =>
s.Status == SearchStatus.New &&
s.UserName == "testuser" &&
s.SubmitDt == null &&
s.StartDt == null &&
s.EndDt == null &&
s.Name == "Original Search"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetResults_ReturnsFileWithCorrectContentType()
{
// Arrange
var excelData = new byte[] { 1, 2, 3, 4, 5 };
_repository.GetSearchResultsAsync(1, Arg.Any<CancellationToken>())
.Returns(excelData);
// Act
var result = await _controller.GetResults(1, CancellationToken.None);
// Assert
result.ShouldBeOfType<FileContentResult>();
var fileResult = (FileContentResult)result;
fileResult.ContentType.ShouldBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
fileResult.FileDownloadName.ShouldBe("search_results.xlsx");
fileResult.FileContents.ShouldBe(excelData);
}
[Fact]
public async Task GetResults_WhenNoResults_Returns404()
{
// Arrange
_repository.GetSearchResultsAsync(1, Arg.Any<CancellationToken>())
.Returns((byte[]?)null);
// Act
var result = await _controller.GetResults(1, CancellationToken.None);
// Assert
result.ShouldBeOfType<NotFoundResult>();
}
[Fact]
public async Task GetSearch_WhenNotFound_Returns404()
{
// Arrange
_repository.GetSearchAsync(999, Arg.Any<CancellationToken>())
.Returns((Search?)null);
// Act
var result = await _controller.GetSearch(999, CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<NotFoundResult>();
}
private void SetupAuthenticatedUser(string username)
{
var claims = new List<Claim>
{
new(ClaimTypes.Name, username),
new("dn", $"CN={username},DC=example,DC=com")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext { User = principal };
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
}
}