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,112 @@
using System.Net;
using System.Net.Http.Json;
using JdeScoping.Api.Models;
using JdeScoping.Core.Models;
using Microsoft.AspNetCore.Mvc.Testing;
using Shouldly;
namespace JdeScoping.Api.IntegrationTests;
/// <summary>
/// Integration tests for authentication flow.
/// Note: These tests require a running test server with UseFakeAuth=true
/// </summary>
public class AuthenticationTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
private readonly HttpClient _client;
public AuthenticationTests(TestWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = true,
AllowAutoRedirect = false
});
}
[Fact]
public async Task FullLoginLogoutFlow_WithCookies()
{
// Step 1: Login
var loginRequest = new LoginRequest { Username = "testuser", Password = "testpass" };
var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", loginRequest);
loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var user = await loginResponse.Content.ReadFromJsonAsync<UserInfo>();
user.ShouldNotBeNull();
user.Username.ShouldBe("testuser");
// Step 2: Verify we can access protected endpoint
var meResponse = await _client.GetAsync("/api/auth/me");
meResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var meUser = await meResponse.Content.ReadFromJsonAsync<UserInfo>();
meUser.ShouldNotBeNull();
meUser.Username.ShouldBe("testuser");
// Step 3: Logout
var logoutResponse = await _client.PostAsync("/api/auth/logout", null);
logoutResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
// Step 4: Verify protected endpoint returns 401 after logout
var afterLogoutResponse = await _client.GetAsync("/api/auth/me");
afterLogoutResponse.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task ProtectedEndpoints_Return401_WithoutAuth()
{
// Use a fresh client without cookies (using factory to connect to test server)
var freshClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = false,
AllowAutoRedirect = false
});
// Search endpoints require auth
var searchResponse = await freshClient.GetAsync("/api/search");
searchResponse.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
// Auth me endpoint requires auth
var meResponse = await freshClient.GetAsync("/api/auth/me");
meResponse.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task ProtectedEndpoints_Work_WithAuthCookie()
{
// Login first
var loginRequest = new LoginRequest { Username = "testuser", Password = "testpass" };
var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", loginRequest);
loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
// Now search endpoint should work
var searchResponse = await _client.GetAsync("/api/search");
searchResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact]
public async Task LookupEndpoints_DoNotRequireAuth()
{
// Use a fresh client without cookies (using factory to connect to test server)
var freshClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = false,
AllowAutoRedirect = false
});
// Lookup endpoints should work without auth
var itemsResponse = await freshClient.GetAsync("/api/lookup/items?q=test");
itemsResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var profitCentersResponse = await freshClient.GetAsync("/api/lookup/profit-centers?q=test");
profitCentersResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var workCentersResponse = await freshClient.GetAsync("/api/lookup/work-centers?q=test");
workCentersResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var operatorsResponse = await freshClient.GetAsync("/api/lookup/operators?q=test");
operatorsResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
}
}
@@ -0,0 +1,51 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Shouldly;
namespace JdeScoping.Api.IntegrationTests;
/// <summary>
/// Integration tests for file controller cache behavior.
/// </summary>
public class FileControllerIntegrationTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
private readonly HttpClient _client;
public FileControllerIntegrationTests(TestWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = true,
AllowAutoRedirect = false
});
}
[Fact]
public async Task DownloadTemplate_WithInvalidCacheKey_Returns404()
{
// Arrange - use a random GUID that won't be in cache
var invalidKey = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/file/work-orders/template/{invalidKey}");
// Assert
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
[Fact]
public async Task DownloadTemplate_WithExpiredOrMissingKey_Returns404()
{
// Arrange - attempt to download with non-existent key
var expiredKey = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/file/part-numbers/template/{expiredKey}");
// Assert
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
}
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\JdeScoping.Api\JdeScoping.Api.csproj" />
<ProjectReference Include="..\..\src\JdeScoping.Host\JdeScoping.Host.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="LdapForNet" Version="2.7.15" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.2.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,155 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Infrastructure;
using JdeScoping.Core.Models.Search;
using Microsoft.AspNetCore.SignalR.Client;
using Shouldly;
namespace JdeScoping.Api.IntegrationTests;
/// <summary>
/// Integration tests for SignalR hub functionality.
/// Note: These tests require a running test server
/// </summary>
public class SignalRTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public SignalRTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Client_CanConnectToStatusHub()
{
// Arrange
var server = _factory.Server;
var hubConnection = new HubConnectionBuilder()
.WithUrl(
new Uri(server.BaseAddress, "/hubs/status"),
options => options.HttpMessageHandlerFactory = _ => server.CreateHandler())
.Build();
// Act
await hubConnection.StartAsync();
// Assert
hubConnection.State.ShouldBe(HubConnectionState.Connected);
// Cleanup
await hubConnection.StopAsync();
}
[Fact]
public async Task Client_CanCallGetCachedStatus()
{
// Arrange
var server = _factory.Server;
var hubConnection = new HubConnectionBuilder()
.WithUrl(
new Uri(server.BaseAddress, "/hubs/status"),
options => options.HttpMessageHandlerFactory = _ => server.CreateHandler())
.Build();
await hubConnection.StartAsync();
// Act
var status = await hubConnection.InvokeAsync<StatusUpdate>("GetCachedStatus");
// Assert
status.ShouldNotBeNull();
status.Message.ShouldNotBeNullOrEmpty();
// Cleanup
await hubConnection.StopAsync();
}
[Fact]
public async Task Client_ReceivesStatusUpdates()
{
// Arrange
var server = _factory.Server;
var hubConnection = new HubConnectionBuilder()
.WithUrl(
new Uri(server.BaseAddress, "/hubs/status"),
options => options.HttpMessageHandlerFactory = _ => server.CreateHandler())
.Build();
StatusUpdate? receivedUpdate = null;
var updateReceived = new TaskCompletionSource<bool>();
hubConnection.On<StatusUpdate>("statusUpdate", update =>
{
receivedUpdate = update;
updateReceived.TrySetResult(true);
});
await hubConnection.StartAsync();
// Act - Call SetStatus
var testUpdate = new StatusUpdate
{
Message = "Test Status",
Timestamp = DateTime.UtcNow
};
await hubConnection.SendAsync("SetStatus", testUpdate);
// Wait for update with timeout
var received = await Task.WhenAny(updateReceived.Task, Task.Delay(TimeSpan.FromSeconds(5)));
// Assert
(received == updateReceived.Task).ShouldBeTrue("Status update was not received within timeout");
receivedUpdate.ShouldNotBeNull();
receivedUpdate.Message.ShouldBe("Test Status");
// Cleanup
await hubConnection.StopAsync();
}
[Fact]
public async Task Client_ReceivesSearchUpdates()
{
// Arrange
var server = _factory.Server;
var hubConnection = new HubConnectionBuilder()
.WithUrl(
new Uri(server.BaseAddress, "/hubs/status"),
options => options.HttpMessageHandlerFactory = _ => server.CreateHandler())
.Build();
SearchUpdate? receivedUpdate = null;
var updateReceived = new TaskCompletionSource<bool>();
hubConnection.On<SearchUpdate>("searchUpdate", update =>
{
receivedUpdate = update;
updateReceived.TrySetResult(true);
});
await hubConnection.StartAsync();
// Act - Call PublishSearchUpdate
var testUpdate = new SearchUpdate
{
Id = 42,
UserName = "testuser",
Name = "Test Search",
Status = SearchStatus.Running,
Timestamp = DateTime.UtcNow
};
await hubConnection.SendAsync("PublishSearchUpdate", testUpdate);
// Wait for update with timeout
var received = await Task.WhenAny(updateReceived.Task, Task.Delay(TimeSpan.FromSeconds(5)));
// Assert
(received == updateReceived.Task).ShouldBeTrue("Search update was not received within timeout");
receivedUpdate.ShouldNotBeNull();
receivedUpdate.Id.ShouldBe(42);
receivedUpdate.Name.ShouldBe("Test Search");
// Cleanup
await hubConnection.StopAsync();
}
}
@@ -0,0 +1,110 @@
using JdeScoping.Core.Interfaces;
using JdeScoping.Infrastructure.Auth;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.Core.Models.Organization;
using JdeScoping.Core.Models.Search;
using JdeScoping.Core.Models.WorkOrders;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace JdeScoping.Api.IntegrationTests;
/// <summary>
/// Test web application factory for integration tests
/// </summary>
public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
// Add test configuration with dummy connection strings
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:SqlServer"] = "Server=localhost;Database=TestDb;Trusted_Connection=true;",
["ConnectionStrings:JDE"] = "Data Source=localhost;User Id=test;Password=test;",
["ConnectionStrings:CMS"] = "Data Source=localhost;User Id=test;Password=test;",
["DataAccess:ConnectionStringName"] = "SqlServer",
["DataSource:JDE:ConnectionStringName"] = "JDE",
["DataSource:CMS:ConnectionStringName"] = "CMS"
});
});
builder.ConfigureServices(services =>
{
// Remove the real repository and add a mock
var repositoryDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(ILotFinderRepository));
if (repositoryDescriptor != null)
{
services.Remove(repositoryDescriptor);
}
// Add mock repository
var mockRepository = CreateMockRepository();
services.AddSingleton(mockRepository);
// Ensure fake auth is used for tests
var authServiceDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IAuthService));
if (authServiceDescriptor != null)
{
services.Remove(authServiceDescriptor);
}
services.AddSingleton<IAuthService, FakeAuthService>();
});
}
private static ILotFinderRepository CreateMockRepository()
{
var repository = Substitute.For<ILotFinderRepository>();
// Setup default returns for search operations
repository.GetUserSearchesAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new List<Search>());
repository.GetQueuedSearchesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Search>());
repository.GetSearchAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns((Search?)null);
repository.GetSearchResultsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns((byte[]?)null);
repository.SubmitSearchAsync(Arg.Any<Search>(), Arg.Any<CancellationToken>())
.Returns(1);
// Setup default returns for lookup operations
repository.SearchItemsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new List<Item>());
repository.SearchProfitCentersAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new List<ProfitCenter>());
repository.SearchWorkCentersAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new List<WorkCenter>());
repository.SearchUsersAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new List<JdeUser>());
// Setup default returns for file upload lookups
repository.LookupWorkordersAsync(Arg.Any<List<long>>(), Arg.Any<CancellationToken>())
.Returns(new List<WorkOrder>());
repository.LookupItemsAsync(Arg.Any<List<string>>(), Arg.Any<CancellationToken>())
.Returns(new List<Item>());
repository.LookupLotsAsync(Arg.Any<List<LotViewModel>>(), Arg.Any<CancellationToken>())
.Returns(new List<Lot>());
return repository;
}
}