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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user