mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 04:13:13 +00:00
test: bootstrap test infrastructure with critical coverage (QA-C1 through QA-C6)
QA-C1: Create xUnit test project, add to solution, wire dependencies - api/tests/ProposalSystem.Tests with xUnit + FluentAssertions + NSubstitute - InMemory EF Core provider for isolated DB tests QA-C2: Proposal state machine transition tests (16 tests) - Valid: InReview->Approved, Approved->Sent, Sent->Revised - Invalid: InReview->Sent, Approved->Revised, Draft->Approved, etc. - Edge cases: idempotency, missing line items, revision line item copying - Audit and job publisher verification QA-C3: Authorization attribute tests (16 tests) - Controller-level [Authorize] on all controllers except AuthController - Role requirements: admins/sysadmins on admin actions - Dispatcher exclusion from admin/sysadmin routes - SysAdmin-only user management enforcement QA-C4: InternalApiKeyMiddleware tests (8 tests) - Valid key sets claims and calls next - Invalid key passes through to JWT (no 401/403) - Missing key/empty config disables middleware - Documents API-C1 vulnerability (key works on any path) QA-C5: ProtectedRoute and RoleGuard tests (12 tests, vitest) - ProtectedRoute: renders children when authenticated, redirects when not - RoleGuard: role-based access, dispatcher/admin/sysadmin enforcement - authSlice: setUser, logout, expired token handling QA-C6: Lambda SQS handler tests (19 tests, pytest) - pdf-generate: batch processing, failure reporting, malformed body - suggestions: batch processing, proposal-not-found skip, AI item preservation - API key caching, retry helpers Total: 107 tests (76 .NET + 12 web + 19 Python), all passing.
This commit is contained in:
parent
2017c0379e
commit
d21b1c5edb
21 changed files with 3053 additions and 17 deletions
|
|
@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Application"
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Infrastructure", "src\ProposalSystem.Infrastructure\ProposalSystem.Infrastructure.csproj", "{A1B2C3D4-1111-2222-3333-444455559999}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Tests", "tests\ProposalSystem.Tests\ProposalSystem.Tests.csproj", "{A1B2C3D4-1111-2222-3333-44445555AAAA}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -33,5 +35,9 @@ Global
|
|||
{A1B2C3D4-1111-2222-3333-444455559999}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455559999}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455559999}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-44445555AAAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-44445555AAAA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-44445555AAAA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-44445555AAAA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
using System.Reflection;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ProposalSystem.Api.Controllers;
|
||||
using Xunit;
|
||||
|
||||
namespace ProposalSystem.Tests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// QA-C3: Authorization attribute tests.
|
||||
/// Verifies that controllers and actions have correct [Authorize] and role requirements.
|
||||
/// These are static metadata tests — they verify the security annotations exist
|
||||
/// and are correct without needing to spin up an HTTP server.
|
||||
/// </summary>
|
||||
public class AuthorizationAttributeTests
|
||||
{
|
||||
#region Controller-Level Authorization
|
||||
|
||||
[Fact(DisplayName = "QA-C3: ProposalsController requires authentication")]
|
||||
public void ProposalsController_HasAuthorizeAttribute()
|
||||
{
|
||||
typeof(ProposalsController)
|
||||
.GetCustomAttribute<AuthorizeAttribute>()
|
||||
.Should().NotBeNull("ProposalsController should require authentication");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C3: AdminController requires admins or sysadmins role")]
|
||||
public void AdminController_RequiresAdminOrSysAdminRole()
|
||||
{
|
||||
var attr = typeof(AdminController).GetCustomAttribute<AuthorizeAttribute>();
|
||||
attr.Should().NotBeNull("AdminController should require authentication");
|
||||
attr!.Roles.Should().NotBeNull();
|
||||
attr.Roles!.Split(',').Select(r => r.Trim()).Should()
|
||||
.Contain("admins").And.Contain("sysadmins");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C3: UsersController requires authentication")]
|
||||
public void UsersController_HasAuthorizeAttribute()
|
||||
{
|
||||
typeof(UsersController)
|
||||
.GetCustomAttribute<AuthorizeAttribute>()
|
||||
.Should().NotBeNull("UsersController should require authentication");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Action-Level Role Requirements
|
||||
|
||||
[Theory(DisplayName = "QA-C3: Admin-only proposal actions require admins/sysadmins role")]
|
||||
[InlineData("Update")]
|
||||
[InlineData("Approve")]
|
||||
[InlineData("MarkSent")]
|
||||
[InlineData("Revise")]
|
||||
[InlineData("GetAudit")]
|
||||
[InlineData("GetSimilar")]
|
||||
[InlineData("GenerateSuggestions")]
|
||||
[InlineData("Regenerate")]
|
||||
[InlineData("AddSimilarReference")]
|
||||
public void ProposalsController_AdminActions_RequireAdminRole(string methodName)
|
||||
{
|
||||
var method = typeof(ProposalsController).GetMethod(methodName);
|
||||
method.Should().NotBeNull($"ProposalsController should have a {methodName} method");
|
||||
|
||||
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
|
||||
attr.Should().NotBeNull($"{methodName} should have [Authorize] attribute");
|
||||
attr!.Roles.Should().NotBeNull($"{methodName} should specify roles");
|
||||
attr.Roles!.Split(',').Select(r => r.Trim()).Should()
|
||||
.Contain("admins", $"{methodName} should allow admins")
|
||||
.And.Contain("sysadmins", $"{methodName} should allow sysadmins");
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "QA-C3: Dispatcher-accessible proposal actions do NOT have role restrictions")]
|
||||
[InlineData("Create")]
|
||||
[InlineData("GetAll")]
|
||||
[InlineData("GetById")]
|
||||
[InlineData("GetHistory")]
|
||||
[InlineData("GetStats")]
|
||||
public void ProposalsController_DispatcherActions_NoRoleRestriction(string methodName)
|
||||
{
|
||||
var method = typeof(ProposalsController).GetMethod(methodName);
|
||||
method.Should().NotBeNull($"ProposalsController should have a {methodName} method");
|
||||
|
||||
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
|
||||
// These methods rely on controller-level [Authorize] but have no role restriction
|
||||
if (attr != null)
|
||||
{
|
||||
attr.Roles.Should().BeNullOrEmpty($"{methodName} should be accessible to all authenticated users");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "QA-C3: SysAdmin-only user management actions require sysadmins role")]
|
||||
[InlineData("GetAll")]
|
||||
[InlineData("UpdateRole")]
|
||||
public void UsersController_SysAdminActions_RequireSysAdminRole(string methodName)
|
||||
{
|
||||
var method = typeof(UsersController).GetMethod(methodName);
|
||||
method.Should().NotBeNull($"UsersController should have a {methodName} method");
|
||||
|
||||
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
|
||||
attr.Should().NotBeNull($"{methodName} should have [Authorize] attribute");
|
||||
attr!.Roles.Should().NotBeNull($"{methodName} should specify roles");
|
||||
attr.Roles!.Split(',').Select(r => r.Trim()).Should()
|
||||
.Contain("sysadmins", $"{methodName} should require sysadmins role");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C3: UsersController.GetMe is accessible to all authenticated users")]
|
||||
public void UsersController_GetMe_NoRoleRestriction()
|
||||
{
|
||||
var method = typeof(UsersController).GetMethod("GetMe");
|
||||
method.Should().NotBeNull();
|
||||
|
||||
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
|
||||
// GetMe should rely on controller-level [Authorize] without role restriction
|
||||
if (attr != null)
|
||||
{
|
||||
attr.Roles.Should().BeNullOrEmpty("GetMe should be accessible to all authenticated users");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Role Hierarchy Verification
|
||||
|
||||
[Fact(DisplayName = "QA-C3: Dispatchers cannot access admin dashboard")]
|
||||
public void AdminController_NotAccessibleToDispatchers()
|
||||
{
|
||||
var attr = typeof(AdminController).GetCustomAttribute<AuthorizeAttribute>();
|
||||
attr.Should().NotBeNull();
|
||||
attr!.Roles.Should().NotBeNull();
|
||||
|
||||
var roles = attr.Roles!.Split(',').Select(r => r.Trim()).ToList();
|
||||
roles.Should().NotContain("dispatchers",
|
||||
"dispatchers must not have access to admin controller");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C3: Dispatchers cannot access user management")]
|
||||
public void UsersController_GetAll_NotAccessibleToDispatchers()
|
||||
{
|
||||
var method = typeof(UsersController).GetMethod("GetAll");
|
||||
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
|
||||
attr.Should().NotBeNull();
|
||||
attr!.Roles.Should().NotBeNull();
|
||||
|
||||
var roles = attr.Roles!.Split(',').Select(r => r.Trim()).ToList();
|
||||
roles.Should().NotContain("dispatchers",
|
||||
"dispatchers must not have access to user management");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C3: Admins cannot access sysadmin-only user management")]
|
||||
public void UsersController_UpdateRole_NotAccessibleToAdmins()
|
||||
{
|
||||
var method = typeof(UsersController).GetMethod("UpdateRole");
|
||||
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
|
||||
attr.Should().NotBeNull();
|
||||
|
||||
var roles = attr!.Roles!.Split(',').Select(r => r.Trim()).ToList();
|
||||
roles.Should().NotContain("admins",
|
||||
"admins must not have access to role management (sysadmins only)");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region All Controllers Must Have [Authorize]
|
||||
|
||||
[Theory(DisplayName = "QA-C3: All API controllers (except AuthController) require authentication")]
|
||||
[InlineData(typeof(ProposalsController))]
|
||||
[InlineData(typeof(AdminController))]
|
||||
[InlineData(typeof(UsersController))]
|
||||
[InlineData(typeof(CustomersController))]
|
||||
[InlineData(typeof(LineItemsController))]
|
||||
[InlineData(typeof(GeneratedPdfsController))]
|
||||
[InlineData(typeof(FilesController))]
|
||||
[InlineData(typeof(VendorProposalsController))]
|
||||
public void AllControllers_HaveAuthorizeAttribute(Type controllerType)
|
||||
{
|
||||
var attr = controllerType.GetCustomAttribute<AuthorizeAttribute>();
|
||||
attr.Should().NotBeNull(
|
||||
$"{controllerType.Name} must have [Authorize] attribute to prevent unauthenticated access");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
22
api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs
Normal file
22
api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using ProposalSystem.Infrastructure.Data;
|
||||
|
||||
namespace ProposalSystem.Tests.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Creates isolated in-memory DbContext instances for unit tests.
|
||||
/// Each call produces a uniquely-named database so tests don't leak state.
|
||||
/// </summary>
|
||||
public static class DbContextFactory
|
||||
{
|
||||
public static ProposalDbContext Create()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ProposalDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}")
|
||||
.Options;
|
||||
|
||||
var context = new ProposalDbContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
using System.Security.Claims;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using ProposalSystem.Api.Middleware;
|
||||
using Xunit;
|
||||
|
||||
namespace ProposalSystem.Tests.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// QA-C4: InternalApiKeyMiddleware tests.
|
||||
/// Validates that internal API key authentication works correctly:
|
||||
/// - Valid key on any path sets system claims and calls next (current behavior)
|
||||
/// - Invalid key logs warning but still calls next (passes through to JWT)
|
||||
/// - Missing key header passes through to next middleware (JWT auth)
|
||||
/// - Empty key in config disables the middleware entirely
|
||||
///
|
||||
/// Note: API-C1 audit finding identified that this middleware applies globally
|
||||
/// and bypasses JWT on ANY route when a valid key is provided. These tests
|
||||
/// document the current behavior; the fix should scope keys to /internal/ paths.
|
||||
/// </summary>
|
||||
public class InternalApiKeyMiddlewareTests
|
||||
{
|
||||
private const string ValidApiKey = "test-internal-api-key-secret-value";
|
||||
|
||||
[Fact(DisplayName = "QA-C4: Valid key sets system claims and calls next")]
|
||||
public async Task ValidKey_SetsClaimsAndCallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
|
||||
context.Request.Headers["X-Internal-Api-Key"] = ValidApiKey;
|
||||
context.Request.Path = "/api/proposals/123";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled().Should().BeTrue("next middleware should be called");
|
||||
context.User.Identity!.IsAuthenticated.Should().BeTrue();
|
||||
context.User.Identity!.AuthenticationType.Should().Be("InternalApiKey");
|
||||
context.User.FindFirst(ClaimTypes.NameIdentifier)!.Value.Should().Be("system");
|
||||
context.User.FindFirst("sub")!.Value.Should().Be("system-lambda-caller");
|
||||
context.User.FindFirst(ClaimTypes.Role)!.Value.Should().Be("admins");
|
||||
context.User.FindFirst("cognito:groups")!.Value.Should().Be("admins");
|
||||
context.User.FindFirst(ClaimTypes.Email)!.Value.Should().Be("system@proposal-system.internal");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C4: Invalid key does not set claims but still calls next (falls through to JWT)")]
|
||||
public async Task InvalidKey_DoesNotSetClaims_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
|
||||
context.Request.Headers["X-Internal-Api-Key"] = "wrong-key";
|
||||
context.Request.Path = "/api/proposals/123";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled().Should().BeTrue("next middleware should still be called for JWT to handle");
|
||||
context.User.Identity!.IsAuthenticated.Should().BeFalse(
|
||||
"invalid key should not authenticate; JWT middleware handles auth next");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C4: Missing key header passes through to next middleware")]
|
||||
public async Task MissingKeyHeader_PassesThrough()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
|
||||
// No X-Internal-Api-Key header set
|
||||
context.Request.Path = "/api/proposals";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled().Should().BeTrue("request should pass through to next middleware");
|
||||
context.User.Identity!.IsAuthenticated.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C4: Empty key in config disables API key check entirely")]
|
||||
public async Task EmptyKeyInConfig_DisablesMiddleware()
|
||||
{
|
||||
// Arrange - empty config key means middleware is effectively disabled
|
||||
var (middleware, context, nextCalled) = CreateMiddleware("");
|
||||
context.Request.Headers["X-Internal-Api-Key"] = "any-key-value";
|
||||
context.Request.Path = "/api/proposals/123";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled().Should().BeTrue("next middleware should be called");
|
||||
context.User.Identity!.IsAuthenticated.Should().BeFalse(
|
||||
"middleware is disabled when config key is empty");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C4: Null config key disables API key check")]
|
||||
public async Task NullConfigKey_DisablesMiddleware()
|
||||
{
|
||||
// Arrange - null config key (INTERNAL_API_KEY not set)
|
||||
var (middleware, context, nextCalled) = CreateMiddleware(null);
|
||||
context.Request.Headers["X-Internal-Api-Key"] = "any-key-value";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled().Should().BeTrue();
|
||||
context.User.Identity!.IsAuthenticated.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C4: Empty header value passes through")]
|
||||
public async Task EmptyHeaderValue_PassesThrough()
|
||||
{
|
||||
// Arrange
|
||||
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
|
||||
context.Request.Headers["X-Internal-Api-Key"] = "";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled().Should().BeTrue();
|
||||
context.User.Identity!.IsAuthenticated.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C4: Timing-safe comparison used (key differs by one char)")]
|
||||
public async Task SimilarKey_DoesNotAuthenticate()
|
||||
{
|
||||
// This test verifies that a key differing by just one character
|
||||
// is still rejected (CryptographicOperations.FixedTimeEquals)
|
||||
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
|
||||
context.Request.Headers["X-Internal-Api-Key"] = ValidApiKey + "x";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
nextCalled().Should().BeTrue();
|
||||
context.User.Identity!.IsAuthenticated.Should().BeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API-C1 documents that the middleware currently authenticates on ANY path.
|
||||
/// This test verifies the current (vulnerable) behavior so that when
|
||||
/// path-scoping is added, this test can be updated to verify the fix.
|
||||
/// </summary>
|
||||
[Fact(DisplayName = "QA-C4/API-C1: Valid key currently authenticates on any path (documents vulnerability)")]
|
||||
public async Task ValidKey_AuthenticatesOnAnyPath_DocumentsApiC1()
|
||||
{
|
||||
// The middleware does not scope to /internal/ paths — API-C1 finding.
|
||||
// This test documents the current behavior.
|
||||
var paths = new[] { "/api/proposals", "/api/admin/dashboard", "/api/users", "/health" };
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
|
||||
context.Request.Headers["X-Internal-Api-Key"] = ValidApiKey;
|
||||
context.Request.Path = path;
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
context.User.Identity!.IsAuthenticated.Should().BeTrue(
|
||||
$"API-C1: middleware currently authenticates on {path} (should be scoped to /internal/ paths)");
|
||||
}
|
||||
}
|
||||
|
||||
private static (InternalApiKeyMiddleware middleware, HttpContext context, Func<bool> nextCalled) CreateMiddleware(string? configuredKey)
|
||||
{
|
||||
var wasNextCalled = false;
|
||||
RequestDelegate next = _ =>
|
||||
{
|
||||
wasNextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
var configData = new Dictionary<string, string?>();
|
||||
if (configuredKey != null)
|
||||
{
|
||||
configData["INTERNAL_API_KEY"] = configuredKey;
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configData)
|
||||
.Build();
|
||||
|
||||
var logger = Substitute.For<ILogger<InternalApiKeyMiddleware>>();
|
||||
|
||||
var middleware = new InternalApiKeyMiddleware(next, configuration, logger);
|
||||
var context = new DefaultHttpContext();
|
||||
|
||||
return (middleware, context, () => wasNextCalled);
|
||||
}
|
||||
}
|
||||
34
api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj
Normal file
34
api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ProposalSystem.Api\ProposalSystem.Api.csproj" />
|
||||
<ProjectReference Include="..\..\src\ProposalSystem.Application\ProposalSystem.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\ProposalSystem.Infrastructure\ProposalSystem.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\src\ProposalSystem.Domain\ProposalSystem.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,430 @@
|
|||
using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using ProposalSystem.Application.Interfaces;
|
||||
using ProposalSystem.Domain.Entities;
|
||||
using ProposalSystem.Infrastructure.Services;
|
||||
using ProposalSystem.Tests.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace ProposalSystem.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// QA-C2: Proposal state machine transition tests.
|
||||
/// Verifies that only valid state transitions succeed and invalid ones throw.
|
||||
/// State machine: InReview -> Approved -> Sent -> Revised (creates new proposal).
|
||||
/// </summary>
|
||||
public class ProposalStateMachineTests : IDisposable
|
||||
{
|
||||
private readonly Infrastructure.Data.ProposalDbContext _db;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly IAuditService _audit;
|
||||
private readonly IJobPublisher _jobPublisher;
|
||||
private readonly IProposalNumberGenerator _numberGenerator;
|
||||
private readonly ProposalService _sut;
|
||||
|
||||
public ProposalStateMachineTests()
|
||||
{
|
||||
_db = DbContextFactory.Create();
|
||||
_currentUser = Substitute.For<ICurrentUserService>();
|
||||
_audit = Substitute.For<IAuditService>();
|
||||
_jobPublisher = Substitute.For<IJobPublisher>();
|
||||
_numberGenerator = Substitute.For<IProposalNumberGenerator>();
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
_currentUser.UserId.Returns(userId);
|
||||
_currentUser.Role.Returns(UserRole.Admin);
|
||||
|
||||
// InMemory provider enforces FK constraints; create the required User entity
|
||||
_db.Users.Add(new User
|
||||
{
|
||||
Id = userId,
|
||||
CognitoSub = $"sub-{userId}",
|
||||
Email = "admin@test.com",
|
||||
DisplayName = "Test Admin",
|
||||
Role = UserRole.Admin,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
_db.SaveChanges();
|
||||
|
||||
_sut = new ProposalService(_db, _currentUser, _numberGenerator, _audit, _jobPublisher);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Dispose();
|
||||
}
|
||||
|
||||
#region Valid Transitions
|
||||
|
||||
[Fact(DisplayName = "QA-C2: InReview -> Approved succeeds when line items have prices")]
|
||||
public async Task ApproveAsync_InReview_TransitionsToApproved()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||
proposal.LineItems.Add(new LineItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = proposal.Id,
|
||||
Description = "HVAC duct replacement",
|
||||
Quantity = 1,
|
||||
Unit = "each",
|
||||
TotalPrice = 5000m,
|
||||
PricingMode = PricingMode.TotalPrice,
|
||||
Source = LineItemSource.AI,
|
||||
});
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _sut.ApproveAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(ProposalStatus.Approved);
|
||||
result.ApprovedById.Should().Be(_currentUser.UserId);
|
||||
result.TotalBidAmount.Should().Be(5000m);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: Approved -> Sent succeeds")]
|
||||
public async Task MarkSentAsync_Approved_TransitionsToSent()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.Approved);
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _sut.MarkSentAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(ProposalStatus.Sent);
|
||||
result.SentAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: Sent -> Revised creates new revision proposal")]
|
||||
public async Task ReviseAsync_Sent_CreatesNewProposalInReview()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.Sent);
|
||||
proposal.LineItems.Add(new LineItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = proposal.Id,
|
||||
Description = "Pipe repair",
|
||||
Quantity = 2,
|
||||
Unit = "hours",
|
||||
UnitPrice = 150m,
|
||||
TotalPrice = 300m,
|
||||
PricingMode = PricingMode.UnitPrice,
|
||||
Source = LineItemSource.Manual,
|
||||
});
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _sut.ReviseAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(ProposalStatus.InReview);
|
||||
result.ParentProposalId.Should().Be(proposal.Id);
|
||||
result.CurrentRevision.Should().Be(proposal.CurrentRevision + 1);
|
||||
result.ProposalNumber.Should().Contain("-R");
|
||||
|
||||
// Original proposal should now be Revised
|
||||
var original = await _db.Proposals.FindAsync(proposal.Id);
|
||||
original!.Status.Should().Be(ProposalStatus.Revised);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: Approve is idempotent on already-approved proposal")]
|
||||
public async Task ApproveAsync_AlreadyApproved_ReturnsWithoutError()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.Approved);
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _sut.ApproveAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(ProposalStatus.Approved);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: MarkSent is idempotent on already-sent proposal")]
|
||||
public async Task MarkSentAsync_AlreadySent_ReturnsWithoutError()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.Sent);
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _sut.MarkSentAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(ProposalStatus.Sent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Invalid Transitions
|
||||
|
||||
[Fact(DisplayName = "QA-C2: InReview -> Sent is invalid, must approve first")]
|
||||
public async Task MarkSentAsync_InReview_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var act = () => _sut.MarkSentAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*approved*");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: Approved -> Revised is invalid, must send first")]
|
||||
public async Task ReviseAsync_Approved_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.Approved);
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var act = () => _sut.ReviseAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*sent*");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: Draft -> Approved is invalid")]
|
||||
public async Task ApproveAsync_Draft_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.Draft);
|
||||
proposal.LineItems.Add(new LineItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = proposal.Id,
|
||||
Description = "Some work",
|
||||
Quantity = 1,
|
||||
Unit = "each",
|
||||
TotalPrice = 100m,
|
||||
PricingMode = PricingMode.TotalPrice,
|
||||
Source = LineItemSource.Manual,
|
||||
});
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var act = () => _sut.ApproveAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*in review*");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: InReview -> Revised is invalid")]
|
||||
public async Task ReviseAsync_InReview_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var act = () => _sut.ReviseAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*sent*");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: Sent -> Approved is invalid")]
|
||||
public async Task MarkSentAsync_Sent_StaysIdempotent_But_ApproveThrows()
|
||||
{
|
||||
// Sent cannot go back to Approved
|
||||
var proposal = CreateProposal(ProposalStatus.Sent);
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var act = () => _sut.ApproveAsync(proposal.Id);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*in review*");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: Cannot approve proposal without priced line items")]
|
||||
public async Task ApproveAsync_NoLineItems_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange - proposal InReview but no line items
|
||||
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var act = () => _sut.ApproveAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*priced line items*");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: Cannot approve proposal with zero-price line items only")]
|
||||
public async Task ApproveAsync_AllZeroPriceLineItems_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||
proposal.LineItems.Add(new LineItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = proposal.Id,
|
||||
Description = "Unprice item",
|
||||
Quantity = 1,
|
||||
Unit = "each",
|
||||
TotalPrice = 0m,
|
||||
PricingMode = PricingMode.TotalPrice,
|
||||
Source = LineItemSource.AI,
|
||||
});
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var act = () => _sut.ApproveAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*priced line items*");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: Approve/Send/Revise on nonexistent proposal throws KeyNotFoundException")]
|
||||
public async Task StateTransitions_NonexistentProposal_ThrowsKeyNotFound()
|
||||
{
|
||||
var missingId = Guid.NewGuid();
|
||||
|
||||
var approveAct = () => _sut.ApproveAsync(missingId);
|
||||
var sendAct = () => _sut.MarkSentAsync(missingId);
|
||||
var reviseAct = () => _sut.ReviseAsync(missingId);
|
||||
|
||||
await approveAct.Should().ThrowAsync<KeyNotFoundException>();
|
||||
await sendAct.Should().ThrowAsync<KeyNotFoundException>();
|
||||
await reviseAct.Should().ThrowAsync<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Revision Edge Cases
|
||||
|
||||
[Fact(DisplayName = "QA-C2: Revision copies line items from parent")]
|
||||
public async Task ReviseAsync_CopiesLineItems()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.Sent);
|
||||
proposal.LineItems.Add(new LineItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = proposal.Id,
|
||||
Description = "Item 1",
|
||||
Quantity = 5,
|
||||
Unit = "sq ft",
|
||||
UnitPrice = 10m,
|
||||
TotalPrice = 50m,
|
||||
PricingMode = PricingMode.UnitPrice,
|
||||
SortOrder = 1,
|
||||
Source = LineItemSource.Manual,
|
||||
});
|
||||
proposal.LineItems.Add(new LineItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = proposal.Id,
|
||||
Description = "Item 2",
|
||||
Quantity = 1,
|
||||
Unit = "each",
|
||||
TotalPrice = 200m,
|
||||
PricingMode = PricingMode.TotalPrice,
|
||||
SortOrder = 2,
|
||||
Source = LineItemSource.AI,
|
||||
});
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _sut.ReviseAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
var revision = await _db.Proposals.FindAsync(result.Id);
|
||||
var revisionItems = _db.LineItems.Where(li => li.ProposalId == result.Id).ToList();
|
||||
revisionItems.Should().HaveCount(2);
|
||||
revisionItems.Should().AllSatisfy(li => li.ProposalId.Should().Be(result.Id));
|
||||
revisionItems.Select(li => li.Description).Should().BeEquivalentTo("Item 1", "Item 2");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: Audit is logged for approve transition")]
|
||||
public async Task ApproveAsync_LogsAuditEvent()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||
proposal.LineItems.Add(new LineItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalId = proposal.Id,
|
||||
Description = "Work item",
|
||||
Quantity = 1,
|
||||
Unit = "each",
|
||||
TotalPrice = 1000m,
|
||||
PricingMode = PricingMode.TotalPrice,
|
||||
Source = LineItemSource.Manual,
|
||||
});
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
await _sut.ApproveAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
await _audit.Received(1).LogAsync(AuditAction.Approve, proposal.Id, Arg.Any<string?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "QA-C2: MarkSent publishes library-ingest job")]
|
||||
public async Task MarkSentAsync_PublishesLibraryIngestJob()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal(ProposalStatus.Approved);
|
||||
_db.Proposals.Add(proposal);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
await _sut.MarkSentAsync(proposal.Id);
|
||||
|
||||
// Assert
|
||||
await _jobPublisher.Received(1).PublishAsync("library-ingest", Arg.Any<object>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private Proposal CreateProposal(ProposalStatus status)
|
||||
{
|
||||
return new Proposal
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProposalNumber = $"P-{Guid.NewGuid():N}".Substring(0, 12),
|
||||
WorkOrderNumber = "WO-001",
|
||||
CustomerName = "Test Customer",
|
||||
CustomerAddress = "123 Test St",
|
||||
ScopeOfWork = "Test scope of work",
|
||||
ServiceCategory = ServiceCategory.HVAC,
|
||||
Priority = Priority.Standard,
|
||||
Status = status,
|
||||
Notes = "",
|
||||
SubmittedById = _currentUser.UserId,
|
||||
SubmittedAt = DateTime.UtcNow.AddDays(-1),
|
||||
CurrentRevision = 1,
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-1),
|
||||
UpdatedAt = DateTime.UtcNow.AddDays(-1),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
using FluentAssertions;
|
||||
using ProposalSystem.Application.DTOs;
|
||||
using ProposalSystem.Application.Validators;
|
||||
using ProposalSystem.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace ProposalSystem.Tests.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CreateLineItemValidator — validates line item payloads.
|
||||
/// </summary>
|
||||
public class CreateLineItemValidatorTests
|
||||
{
|
||||
private readonly CreateLineItemValidator _sut = new();
|
||||
|
||||
[Fact(DisplayName = "Valid line item request passes validation")]
|
||||
public void ValidRequest_Passes()
|
||||
{
|
||||
var request = new CreateLineItemRequest(
|
||||
Description: "HVAC duct replacement",
|
||||
Quantity: 10,
|
||||
Unit: "linear ft",
|
||||
UnitPrice: 25.50m,
|
||||
TotalPrice: 255.00m,
|
||||
PricingMode: PricingMode.UnitPrice,
|
||||
SortOrder: 1,
|
||||
Source: LineItemSource.Manual
|
||||
);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Empty description fails")]
|
||||
public void EmptyDescription_Fails()
|
||||
{
|
||||
var request = new CreateLineItemRequest("", 1, "each", null, 100m,
|
||||
PricingMode.TotalPrice, 1, LineItemSource.Manual);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "Description");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Zero quantity fails")]
|
||||
public void ZeroQuantity_Fails()
|
||||
{
|
||||
var request = new CreateLineItemRequest("Work item", 0, "each", null, 100m,
|
||||
PricingMode.TotalPrice, 1, LineItemSource.Manual);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "Quantity");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Negative quantity fails")]
|
||||
public void NegativeQuantity_Fails()
|
||||
{
|
||||
var request = new CreateLineItemRequest("Work item", -5, "each", null, 100m,
|
||||
PricingMode.TotalPrice, 1, LineItemSource.Manual);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "Quantity");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Negative total price fails")]
|
||||
public void NegativeTotalPrice_Fails()
|
||||
{
|
||||
var request = new CreateLineItemRequest("Work item", 1, "each", null, -100m,
|
||||
PricingMode.TotalPrice, 1, LineItemSource.Manual);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "TotalPrice");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Negative unit price fails")]
|
||||
public void NegativeUnitPrice_Fails()
|
||||
{
|
||||
var request = new CreateLineItemRequest("Work item", 1, "each", -10m, 100m,
|
||||
PricingMode.UnitPrice, 1, LineItemSource.Manual);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "UnitPrice");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Null unit price is valid (lump sum pricing)")]
|
||||
public void NullUnitPrice_Passes()
|
||||
{
|
||||
var request = new CreateLineItemRequest("Work item", 1, "each", null, 100m,
|
||||
PricingMode.TotalPrice, 1, LineItemSource.Manual);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Empty unit fails")]
|
||||
public void EmptyUnit_Fails()
|
||||
{
|
||||
var request = new CreateLineItemRequest("Work item", 1, "", null, 100m,
|
||||
PricingMode.TotalPrice, 1, LineItemSource.Manual);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "Unit");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Negative sort order fails")]
|
||||
public void NegativeSortOrder_Fails()
|
||||
{
|
||||
var request = new CreateLineItemRequest("Work item", 1, "each", null, 100m,
|
||||
PricingMode.TotalPrice, -1, LineItemSource.Manual);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "SortOrder");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
using FluentAssertions;
|
||||
using ProposalSystem.Application.DTOs;
|
||||
using ProposalSystem.Application.Validators;
|
||||
using ProposalSystem.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace ProposalSystem.Tests.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CreateProposalValidator — validates request payloads before
|
||||
/// they hit the service layer.
|
||||
/// </summary>
|
||||
public class CreateProposalValidatorTests
|
||||
{
|
||||
private readonly CreateProposalValidator _sut = new();
|
||||
|
||||
[Fact(DisplayName = "Valid proposal request passes validation")]
|
||||
public void ValidRequest_Passes()
|
||||
{
|
||||
var request = new CreateProposalRequest(
|
||||
WorkOrderNumber: "WO-001",
|
||||
CustomerName: "Acme Corp",
|
||||
CustomerAddress: "123 Main St, Suite 100",
|
||||
ScopeOfWork: "Replace HVAC system in building B",
|
||||
ServiceCategory: ServiceCategory.HVAC,
|
||||
Priority: Priority.Standard,
|
||||
Notes: "Initial assessment complete"
|
||||
);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "Empty required fields fail validation")]
|
||||
[InlineData("", "Customer", "Address", "Scope")]
|
||||
[InlineData("WO-001", "", "Address", "Scope")]
|
||||
[InlineData("WO-001", "Customer", "", "Scope")]
|
||||
[InlineData("WO-001", "Customer", "Address", "")]
|
||||
public void EmptyRequiredFields_Fail(string wo, string name, string address, string scope)
|
||||
{
|
||||
var request = new CreateProposalRequest(wo, name, address, scope,
|
||||
ServiceCategory.General, Priority.Standard, null);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "WorkOrderNumber exceeding 50 chars fails")]
|
||||
public void WorkOrderNumber_TooLong_Fails()
|
||||
{
|
||||
var request = new CreateProposalRequest(
|
||||
WorkOrderNumber: new string('X', 51),
|
||||
CustomerName: "Customer",
|
||||
CustomerAddress: "Address",
|
||||
ScopeOfWork: "Scope",
|
||||
ServiceCategory: ServiceCategory.General,
|
||||
Priority: Priority.Standard,
|
||||
Notes: null
|
||||
);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "WorkOrderNumber");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CustomerName exceeding 200 chars fails")]
|
||||
public void CustomerName_TooLong_Fails()
|
||||
{
|
||||
var request = new CreateProposalRequest(
|
||||
WorkOrderNumber: "WO-001",
|
||||
CustomerName: new string('X', 201),
|
||||
CustomerAddress: "Address",
|
||||
ScopeOfWork: "Scope",
|
||||
ServiceCategory: ServiceCategory.General,
|
||||
Priority: Priority.Standard,
|
||||
Notes: null
|
||||
);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "CustomerName");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ScopeOfWork exceeding 10000 chars fails")]
|
||||
public void ScopeOfWork_TooLong_Fails()
|
||||
{
|
||||
var request = new CreateProposalRequest(
|
||||
WorkOrderNumber: "WO-001",
|
||||
CustomerName: "Customer",
|
||||
CustomerAddress: "Address",
|
||||
ScopeOfWork: new string('X', 10001),
|
||||
ServiceCategory: ServiceCategory.General,
|
||||
Priority: Priority.Standard,
|
||||
Notes: null
|
||||
);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "ScopeOfWork");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Notes exceeding 5000 chars fails")]
|
||||
public void Notes_TooLong_Fails()
|
||||
{
|
||||
var request = new CreateProposalRequest(
|
||||
WorkOrderNumber: "WO-001",
|
||||
CustomerName: "Customer",
|
||||
CustomerAddress: "Address",
|
||||
ScopeOfWork: "Scope",
|
||||
ServiceCategory: ServiceCategory.General,
|
||||
Priority: Priority.Standard,
|
||||
Notes: new string('X', 5001)
|
||||
);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "Notes");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Null notes are valid")]
|
||||
public void NullNotes_Passes()
|
||||
{
|
||||
var request = new CreateProposalRequest(
|
||||
WorkOrderNumber: "WO-001",
|
||||
CustomerName: "Customer",
|
||||
CustomerAddress: "Address",
|
||||
ScopeOfWork: "Scope",
|
||||
ServiceCategory: ServiceCategory.General,
|
||||
Priority: Priority.Standard,
|
||||
Notes: null
|
||||
);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invalid ServiceCategory enum value fails")]
|
||||
public void InvalidServiceCategory_Fails()
|
||||
{
|
||||
var request = new CreateProposalRequest(
|
||||
WorkOrderNumber: "WO-001",
|
||||
CustomerName: "Customer",
|
||||
CustomerAddress: "Address",
|
||||
ScopeOfWork: "Scope",
|
||||
ServiceCategory: (ServiceCategory)999,
|
||||
Priority: Priority.Standard,
|
||||
Notes: null
|
||||
);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "ServiceCategory");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Invalid Priority enum value fails")]
|
||||
public void InvalidPriority_Fails()
|
||||
{
|
||||
var request = new CreateProposalRequest(
|
||||
WorkOrderNumber: "WO-001",
|
||||
CustomerName: "Customer",
|
||||
CustomerAddress: "Address",
|
||||
ScopeOfWork: "Scope",
|
||||
ServiceCategory: ServiceCategory.General,
|
||||
Priority: (Priority)999,
|
||||
Notes: null
|
||||
);
|
||||
|
||||
var result = _sut.Validate(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "Priority");
|
||||
}
|
||||
}
|
||||
3
lambdas/pytest.ini
Normal file
3
lambdas/pytest.ini
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[pytest]
|
||||
testpaths = tests
|
||||
pythonpath = tests
|
||||
0
lambdas/tests/__init__.py
Normal file
0
lambdas/tests/__init__.py
Normal file
19
lambdas/tests/conftest.py
Normal file
19
lambdas/tests/conftest.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Common fixtures for Lambda tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _env_setup(monkeypatch):
|
||||
"""Set environment variables required by all Lambdas."""
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
monkeypatch.setenv("AWS_SECURITY_TOKEN", "testing")
|
||||
monkeypatch.setenv("AWS_SESSION_TOKEN", "testing")
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
monkeypatch.setenv("GENERATED_BUCKET", "test-generated-pdfs")
|
||||
monkeypatch.setenv("API_BASE_URL", "http://localhost:5000")
|
||||
monkeypatch.setenv("INTERNAL_API_KEY_SECRET_ARN", "")
|
||||
monkeypatch.setenv("KNOWLEDGE_BASE_ID", "")
|
||||
monkeypatch.setenv("MODEL_ID", "us.anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
21
lambdas/tests/helpers.py
Normal file
21
lambdas/tests/helpers.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Test helpers for Lambda tests."""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def make_sqs_event(*bodies: dict) -> dict:
|
||||
"""Build a minimal SQS event with the given record bodies."""
|
||||
records = []
|
||||
for i, body in enumerate(bodies):
|
||||
records.append({
|
||||
"messageId": f"msg-{i}",
|
||||
"body": json.dumps(body),
|
||||
"receiptHandle": f"handle-{i}",
|
||||
"attributes": {},
|
||||
"messageAttributes": {},
|
||||
"md5OfBody": "",
|
||||
"eventSource": "aws:sqs",
|
||||
"eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:test-queue",
|
||||
"awsRegion": "us-east-1",
|
||||
})
|
||||
return {"Records": records}
|
||||
4
lambdas/tests/requirements-test.txt
Normal file
4
lambdas/tests/requirements-test.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pytest>=8.0.0
|
||||
pytest-mock>=3.14.0
|
||||
moto[s3,secretsmanager,sqs]>=5.0.0
|
||||
httpx>=0.27.0
|
||||
151
lambdas/tests/test_pdf_generate.py
Normal file
151
lambdas/tests/test_pdf_generate.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Tests for pdf-generate Lambda handler.
|
||||
|
||||
QA-C6 (partial): Verifies SQS batch processing, error handling, and
|
||||
batch failure reporting for the PDF generation pipeline.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import make_sqs_event
|
||||
|
||||
# Import pdf-generate app under a unique module name to avoid collision with
|
||||
# suggestions/app.py (both are named 'app').
|
||||
_pdf_gen_dir = os.path.join(os.path.dirname(__file__), "..", "pdf-generate")
|
||||
_spec = importlib.util.spec_from_file_location("pdf_generate_app", os.path.join(_pdf_gen_dir, "app.py"))
|
||||
pdf_generate_app = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["pdf_generate_app"] = pdf_generate_app
|
||||
_spec.loader.exec_module(pdf_generate_app)
|
||||
|
||||
|
||||
class TestPdfGenerateHandler:
|
||||
"""Test the SQS handler entry point for pdf-generate Lambda."""
|
||||
|
||||
@patch.object(pdf_generate_app, "generate_pdf")
|
||||
def test_handler_processes_sqs_record_successfully(self, mock_generate_pdf):
|
||||
"""QA-C6: Handler extracts proposalId from SQS body and calls generate_pdf."""
|
||||
mock_generate_pdf.return_value = None
|
||||
|
||||
event = make_sqs_event({"payload": {"proposalId": "abc-123"}})
|
||||
result = pdf_generate_app.handler(event, None)
|
||||
|
||||
mock_generate_pdf.assert_called_once_with("abc-123")
|
||||
assert result["batchItemFailures"] == []
|
||||
|
||||
@patch.object(pdf_generate_app, "generate_pdf")
|
||||
def test_handler_processes_multiple_records(self, mock_generate_pdf):
|
||||
"""QA-C6: Handler processes all records in an SQS batch."""
|
||||
mock_generate_pdf.return_value = None
|
||||
|
||||
event = make_sqs_event(
|
||||
{"payload": {"proposalId": "id-1"}},
|
||||
{"payload": {"proposalId": "id-2"}},
|
||||
{"payload": {"proposalId": "id-3"}},
|
||||
)
|
||||
result = pdf_generate_app.handler(event, None)
|
||||
|
||||
assert mock_generate_pdf.call_count == 3
|
||||
assert result["batchItemFailures"] == []
|
||||
|
||||
@patch.object(pdf_generate_app, "generate_pdf")
|
||||
def test_handler_returns_batch_failures_on_error(self, mock_generate_pdf):
|
||||
"""QA-C6: Handler returns failed message IDs for partial batch failure."""
|
||||
mock_generate_pdf.side_effect = [None, Exception("PDF generation failed"), None]
|
||||
|
||||
event = make_sqs_event(
|
||||
{"payload": {"proposalId": "id-1"}},
|
||||
{"payload": {"proposalId": "id-2"}},
|
||||
{"payload": {"proposalId": "id-3"}},
|
||||
)
|
||||
result = pdf_generate_app.handler(event, None)
|
||||
|
||||
assert len(result["batchItemFailures"]) == 1
|
||||
assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-1"
|
||||
|
||||
@patch.object(pdf_generate_app, "generate_pdf")
|
||||
def test_handler_handles_malformed_body(self, mock_generate_pdf):
|
||||
"""QA-C6: Handler reports failure for records with invalid JSON body."""
|
||||
event = {
|
||||
"Records": [
|
||||
{
|
||||
"messageId": "msg-bad",
|
||||
"body": "not-valid-json",
|
||||
"receiptHandle": "handle-0",
|
||||
}
|
||||
]
|
||||
}
|
||||
result = pdf_generate_app.handler(event, None)
|
||||
|
||||
assert len(result["batchItemFailures"]) == 1
|
||||
assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-bad"
|
||||
mock_generate_pdf.assert_not_called()
|
||||
|
||||
@patch.object(pdf_generate_app, "generate_pdf")
|
||||
def test_handler_handles_missing_proposal_id(self, mock_generate_pdf):
|
||||
"""QA-C6: Handler reports failure when proposalId is missing from payload."""
|
||||
event = make_sqs_event({"payload": {}})
|
||||
result = pdf_generate_app.handler(event, None)
|
||||
|
||||
assert len(result["batchItemFailures"]) == 1
|
||||
mock_generate_pdf.assert_not_called()
|
||||
|
||||
@patch.object(pdf_generate_app, "generate_pdf")
|
||||
def test_handler_handles_empty_records(self, mock_generate_pdf):
|
||||
"""QA-C6: Handler handles empty Records array gracefully."""
|
||||
result = pdf_generate_app.handler({"Records": []}, None)
|
||||
|
||||
assert result["batchItemFailures"] == []
|
||||
mock_generate_pdf.assert_not_called()
|
||||
|
||||
@patch.object(pdf_generate_app, "generate_pdf")
|
||||
def test_handler_handles_body_without_payload_wrapper(self, mock_generate_pdf):
|
||||
"""QA-C6: Handler supports body with proposalId at top level (no payload wrapper)."""
|
||||
mock_generate_pdf.return_value = None
|
||||
|
||||
event = make_sqs_event({"proposalId": "direct-id"})
|
||||
result = pdf_generate_app.handler(event, None)
|
||||
|
||||
mock_generate_pdf.assert_called_once_with("direct-id")
|
||||
assert result["batchItemFailures"] == []
|
||||
|
||||
|
||||
class TestPdfGenerateHelpers:
|
||||
"""Test helper functions in the pdf-generate Lambda."""
|
||||
|
||||
@patch.object(pdf_generate_app, "_retry_request")
|
||||
def test_fetch_proposal_returns_dict_on_200(self, mock_retry):
|
||||
"""QA-C6: fetch_proposal returns proposal dict on 200 response."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"id": "abc", "proposalNumber": "P-001"}
|
||||
mock_retry.return_value = mock_response
|
||||
|
||||
result = pdf_generate_app.fetch_proposal("abc")
|
||||
|
||||
assert result is not None
|
||||
assert result["proposalNumber"] == "P-001"
|
||||
|
||||
@patch.object(pdf_generate_app, "_retry_request")
|
||||
def test_fetch_proposal_returns_none_on_404(self, mock_retry):
|
||||
"""QA-C6: fetch_proposal returns None when API returns 404."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
mock_retry.return_value = mock_response
|
||||
|
||||
result = pdf_generate_app.fetch_proposal("nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch.object(pdf_generate_app, "_retry_request")
|
||||
def test_fetch_proposal_returns_none_on_exception(self, mock_retry):
|
||||
"""QA-C6: fetch_proposal returns None on network error."""
|
||||
mock_retry.side_effect = Exception("Connection refused")
|
||||
|
||||
result = pdf_generate_app.fetch_proposal("abc")
|
||||
|
||||
assert result is None
|
||||
217
lambdas/tests/test_suggestions.py
Normal file
217
lambdas/tests/test_suggestions.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""Tests for suggestions Lambda handler.
|
||||
|
||||
QA-C6 (partial): Verifies SQS batch processing, idempotency guard
|
||||
(skips when AI items exist), and error handling.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import make_sqs_event
|
||||
|
||||
# Import suggestions app under a unique module name to avoid collision with
|
||||
# pdf-generate/app.py (both are named 'app').
|
||||
_suggestions_dir = os.path.join(os.path.dirname(__file__), "..", "suggestions")
|
||||
_spec = importlib.util.spec_from_file_location("suggestions_app", os.path.join(_suggestions_dir, "app.py"))
|
||||
suggestions_app = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["suggestions_app"] = suggestions_app
|
||||
_spec.loader.exec_module(suggestions_app)
|
||||
|
||||
|
||||
class TestSuggestionsHandler:
|
||||
"""Test the SQS handler entry point for suggestions Lambda."""
|
||||
|
||||
@patch.object(suggestions_app, "process_suggestion")
|
||||
def test_handler_processes_sqs_record(self, mock_process):
|
||||
"""QA-C6: Handler extracts proposalId and trigger from SQS body."""
|
||||
event = make_sqs_event({"payload": {"proposalId": "abc-123", "trigger": "generate"}})
|
||||
result = suggestions_app.handler(event, None)
|
||||
|
||||
mock_process.assert_called_once_with("abc-123", "generate")
|
||||
assert result["batchItemFailures"] == []
|
||||
|
||||
@patch.object(suggestions_app, "process_suggestion")
|
||||
def test_handler_defaults_trigger_to_generate(self, mock_process):
|
||||
"""QA-C6: Handler defaults trigger to 'generate' when not specified."""
|
||||
event = make_sqs_event({"payload": {"proposalId": "abc-123"}})
|
||||
result = suggestions_app.handler(event, None)
|
||||
|
||||
mock_process.assert_called_once_with("abc-123", "generate")
|
||||
assert result["batchItemFailures"] == []
|
||||
|
||||
@patch.object(suggestions_app, "process_suggestion")
|
||||
def test_handler_returns_batch_failures_on_error(self, mock_process):
|
||||
"""QA-C6: Handler returns failed message IDs for partial batch failure."""
|
||||
mock_process.side_effect = [None, RuntimeError("Bedrock timeout")]
|
||||
|
||||
event = make_sqs_event(
|
||||
{"payload": {"proposalId": "id-1", "trigger": "generate"}},
|
||||
{"payload": {"proposalId": "id-2", "trigger": "generate"}},
|
||||
)
|
||||
result = suggestions_app.handler(event, None)
|
||||
|
||||
assert len(result["batchItemFailures"]) == 1
|
||||
assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-1"
|
||||
|
||||
@patch.object(suggestions_app, "process_suggestion")
|
||||
def test_handler_handles_malformed_body(self, mock_process):
|
||||
"""QA-C6: Handler reports failure for records with invalid JSON."""
|
||||
event = {
|
||||
"Records": [
|
||||
{
|
||||
"messageId": "msg-bad",
|
||||
"body": "{invalid json",
|
||||
"receiptHandle": "handle-0",
|
||||
}
|
||||
]
|
||||
}
|
||||
result = suggestions_app.handler(event, None)
|
||||
|
||||
assert len(result["batchItemFailures"]) == 1
|
||||
mock_process.assert_not_called()
|
||||
|
||||
|
||||
class TestProcessSuggestion:
|
||||
"""Test the core suggestion generation logic."""
|
||||
|
||||
@patch.object(suggestions_app, "store_similar_references")
|
||||
@patch.object(suggestions_app, "post_line_items")
|
||||
@patch.object(suggestions_app, "generate_line_items")
|
||||
@patch.object(suggestions_app, "retrieve_similar")
|
||||
@patch.object(suggestions_app, "fetch_line_items")
|
||||
@patch.object(suggestions_app, "fetch_proposal")
|
||||
def test_process_suggestion_full_flow(
|
||||
self,
|
||||
mock_fetch_proposal,
|
||||
mock_fetch_items,
|
||||
mock_retrieve,
|
||||
mock_generate,
|
||||
mock_post,
|
||||
mock_store_refs,
|
||||
):
|
||||
"""QA-C6: Full suggestion flow fetches proposal, retrieves similar, generates, posts."""
|
||||
mock_fetch_proposal.return_value = {
|
||||
"id": "abc",
|
||||
"scopeOfWork": "Replace HVAC system",
|
||||
"serviceCategory": "HVAC",
|
||||
"priority": "Standard",
|
||||
}
|
||||
mock_fetch_items.return_value = []
|
||||
mock_retrieve.return_value = [{"content": "similar doc", "score": 0.9, "metadata": {}, "sourceUri": ""}]
|
||||
mock_generate.return_value = [
|
||||
{"description": "Ductwork", "quantity": 100, "unit": "linear ft", "unitPrice": 15.0, "totalPrice": 1500.0, "pricingMode": "UnitPrice"},
|
||||
]
|
||||
|
||||
suggestions_app.process_suggestion("abc", "generate")
|
||||
|
||||
mock_fetch_proposal.assert_called_once_with("abc")
|
||||
mock_fetch_items.assert_called_once_with("abc")
|
||||
mock_retrieve.assert_called_once()
|
||||
mock_generate.assert_called_once()
|
||||
mock_post.assert_called_once()
|
||||
|
||||
@patch.object(suggestions_app, "fetch_proposal")
|
||||
def test_process_suggestion_skips_when_proposal_not_found(self, mock_fetch):
|
||||
"""QA-C6: Skips processing when proposal is not found (returns early)."""
|
||||
mock_fetch.return_value = None
|
||||
|
||||
# Should not raise - just logs and returns
|
||||
suggestions_app.process_suggestion("nonexistent", "generate")
|
||||
|
||||
@patch.object(suggestions_app, "_retry_request")
|
||||
def test_post_line_items_preserves_non_ai_items(self, mock_request):
|
||||
"""QA-C6: Existing non-AI items are preserved when new suggestions are generated."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
existing_items = [
|
||||
{"id": "manual-1", "description": "Manual item", "quantity": 1, "unit": "each",
|
||||
"totalPrice": 500, "pricingMode": "TotalPrice", "source": "Manual"},
|
||||
{"id": "ai-1", "description": "Old AI item", "quantity": 1, "unit": "each",
|
||||
"totalPrice": 200, "pricingMode": "TotalPrice", "source": "AI"},
|
||||
]
|
||||
new_items = [
|
||||
{"description": "New AI item", "quantity": 2, "unit": "hours",
|
||||
"unitPrice": 100, "totalPrice": 200, "pricingMode": "UnitPrice"},
|
||||
]
|
||||
|
||||
suggestions_app.post_line_items("abc", new_items, existing_items)
|
||||
|
||||
# Verify the API was called
|
||||
mock_request.assert_called_once()
|
||||
call_kwargs = mock_request.call_args
|
||||
payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
|
||||
|
||||
# Should have 2 items: 1 preserved Manual + 1 new AI (old AI items are replaced)
|
||||
assert len(payload["lineItems"]) == 2
|
||||
sources = [li["source"] for li in payload["lineItems"]]
|
||||
assert "Manual" in sources
|
||||
assert "AI" in sources
|
||||
# The manual item should be first (preserved), AI item second (new)
|
||||
assert payload["lineItems"][0]["source"] == "Manual"
|
||||
assert payload["lineItems"][0]["description"] == "Manual item"
|
||||
assert payload["lineItems"][1]["source"] == "AI"
|
||||
assert payload["lineItems"][1]["description"] == "New AI item"
|
||||
|
||||
@patch.object(suggestions_app, "post_line_items")
|
||||
@patch.object(suggestions_app, "generate_line_items")
|
||||
@patch.object(suggestions_app, "retrieve_similar")
|
||||
@patch.object(suggestions_app, "fetch_line_items")
|
||||
@patch.object(suggestions_app, "fetch_proposal")
|
||||
def test_process_suggestion_skips_when_no_items_and_no_suggestions(
|
||||
self,
|
||||
mock_fetch_proposal,
|
||||
mock_fetch_items,
|
||||
mock_retrieve,
|
||||
mock_generate,
|
||||
mock_post,
|
||||
):
|
||||
"""QA-C6: Skips status update when no existing items and no suggestions generated."""
|
||||
mock_fetch_proposal.return_value = {
|
||||
"id": "abc",
|
||||
"scopeOfWork": "Vague scope",
|
||||
"serviceCategory": "General",
|
||||
"priority": "Standard",
|
||||
}
|
||||
mock_fetch_items.return_value = []
|
||||
mock_retrieve.return_value = []
|
||||
mock_generate.return_value = []
|
||||
|
||||
suggestions_app.process_suggestion("abc", "generate")
|
||||
|
||||
mock_post.assert_not_called()
|
||||
|
||||
|
||||
class TestRetrySafety:
|
||||
"""Test retry and API communication helpers."""
|
||||
|
||||
def test_get_api_key_caches_result(self):
|
||||
"""QA-C6: API key is fetched once and cached for subsequent calls."""
|
||||
# Reset cached key
|
||||
suggestions_app._cached_api_key = None
|
||||
|
||||
mock_secrets = MagicMock()
|
||||
mock_secrets.get_secret_value.return_value = {"SecretString": "test-key"}
|
||||
|
||||
original_client = suggestions_app.secrets_client
|
||||
original_arn = suggestions_app.INTERNAL_API_KEY_SECRET_ARN
|
||||
suggestions_app.secrets_client = mock_secrets
|
||||
suggestions_app.INTERNAL_API_KEY_SECRET_ARN = "arn:aws:secretsmanager:us-east-1:123:secret:key"
|
||||
try:
|
||||
key1 = suggestions_app._get_api_key()
|
||||
key2 = suggestions_app._get_api_key()
|
||||
|
||||
assert key1 == "test-key"
|
||||
assert key2 == "test-key"
|
||||
# Should only call secrets manager once (cached)
|
||||
mock_secrets.get_secret_value.assert_called_once()
|
||||
finally:
|
||||
suggestions_app.INTERNAL_API_KEY_SECRET_ARN = original_arn
|
||||
suggestions_app.secrets_client = original_client
|
||||
suggestions_app._cached_api_key = None
|
||||
1178
web/package-lock.json
generated
1178
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,9 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
|
|
@ -14,7 +16,7 @@
|
|||
"@mui/icons-material": "^7.3.1",
|
||||
"@mui/material": "^7.3.1",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tanstack/react-query": "^5.100.13",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"axios": "^1.16.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
|
|
@ -23,10 +25,15 @@
|
|||
"react-toastify": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.15",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
114
web/src/app/__tests__/authSlice.test.ts
Normal file
114
web/src/app/__tests__/authSlice.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* QA-C5: Auth slice tests.
|
||||
*
|
||||
* Tests the Redux auth state management:
|
||||
* - setUser stores user and marks authenticated
|
||||
* - logout clears user and marks unauthenticated
|
||||
* - Expired token sets isAuthenticated to false
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import authReducer, { setUser, logout, selectUser, selectIsAuthenticated } from '../slices/authSlice';
|
||||
import type { AuthUser } from '../../lib/api/auth';
|
||||
|
||||
// Mock localStorage
|
||||
const mockStorage: Record<string, string> = {};
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => mockStorage[key] ?? null,
|
||||
setItem: (key: string, value: string) => { mockStorage[key] = value; },
|
||||
removeItem: (key: string) => { delete mockStorage[key]; },
|
||||
clear: () => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); },
|
||||
});
|
||||
|
||||
function createValidToken(): string {
|
||||
// Create a JWT-like token with exp far in the future
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256' }));
|
||||
const payload = btoa(JSON.stringify({
|
||||
sub: 'test-user',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
||||
}));
|
||||
return `${header}.${payload}.fake-signature`;
|
||||
}
|
||||
|
||||
function createExpiredToken(): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256' }));
|
||||
const payload = btoa(JSON.stringify({
|
||||
sub: 'test-user',
|
||||
exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
|
||||
}));
|
||||
return `${header}.${payload}.fake-signature`;
|
||||
}
|
||||
|
||||
describe('authSlice', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
|
||||
});
|
||||
|
||||
it('QA-C5: setUser stores user and marks authenticated with valid token', () => {
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
email: 'admin@test.com',
|
||||
displayName: 'Admin',
|
||||
role: 'Admin',
|
||||
token: createValidToken(),
|
||||
};
|
||||
|
||||
const initialState = { user: null, isAuthenticated: false, loading: false, error: null };
|
||||
const state = authReducer(initialState, setUser(user));
|
||||
|
||||
expect(state.user).toEqual(user);
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('QA-C5: setUser with expired token sets isAuthenticated to false', () => {
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
email: 'admin@test.com',
|
||||
displayName: 'Admin',
|
||||
role: 'Admin',
|
||||
token: createExpiredToken(),
|
||||
};
|
||||
|
||||
const initialState = { user: null, isAuthenticated: false, loading: false, error: null };
|
||||
const state = authReducer(initialState, setUser(user));
|
||||
|
||||
expect(state.user).toEqual(user);
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('QA-C5: logout clears user and isAuthenticated', () => {
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
email: 'admin@test.com',
|
||||
displayName: 'Admin',
|
||||
role: 'Admin',
|
||||
token: createValidToken(),
|
||||
};
|
||||
|
||||
const loggedInState = { user, isAuthenticated: true, loading: false, error: null };
|
||||
const state = authReducer(loggedInState, logout());
|
||||
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('QA-C5: selectUser returns user from state', () => {
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
email: 'admin@test.com',
|
||||
displayName: 'Admin',
|
||||
role: 'Admin',
|
||||
token: createValidToken(),
|
||||
};
|
||||
|
||||
const state = { auth: { user, isAuthenticated: true, loading: false, error: null } };
|
||||
expect(selectUser(state)).toEqual(user);
|
||||
});
|
||||
|
||||
it('QA-C5: selectIsAuthenticated returns false when no user', () => {
|
||||
const state = { auth: { user: null, isAuthenticated: false, loading: false, error: null } };
|
||||
expect(selectIsAuthenticated(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
153
web/src/components/__tests__/ProtectedRoute.test.tsx
Normal file
153
web/src/components/__tests__/ProtectedRoute.test.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* QA-C5: ProtectedRoute and RoleGuard component tests.
|
||||
*
|
||||
* Tests that:
|
||||
* - ProtectedRoute renders children when authenticated
|
||||
* - ProtectedRoute redirects to /login when not authenticated
|
||||
* - RoleGuard renders children when user has correct role
|
||||
* - RoleGuard redirects to / when user has wrong role
|
||||
* - RoleGuard redirects to / when user is null
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import ProtectedRoute, { RoleGuard } from '../ProtectedRoute';
|
||||
|
||||
// Mock the useAuth hook
|
||||
const mockUseAuth = vi.fn();
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
function renderWithRouter(ui: React.ReactElement, initialRoute = '/protected') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
|
||||
<Route path="/" element={<div data-testid="home-page">Home Page</div>} />
|
||||
<Route path="/protected" element={ui} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('QA-C5: renders children when user is authenticated', () => {
|
||||
mockUseAuth.mockReturnValue({ isAuthenticated: true, user: null, loading: false });
|
||||
|
||||
renderWithRouter(
|
||||
<ProtectedRoute>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</ProtectedRoute>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('login-page')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('QA-C5: redirects to /login when user is not authenticated', () => {
|
||||
mockUseAuth.mockReturnValue({ isAuthenticated: false, user: null, loading: false });
|
||||
|
||||
renderWithRouter(
|
||||
<ProtectedRoute>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</ProtectedRoute>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('login-page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RoleGuard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('QA-C5: renders children when user has an allowed role', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
user: { id: '1', email: 'admin@test.com', displayName: 'Admin', role: 'Admin', token: 'tok' },
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<RoleGuard roles={['Admin', 'SysAdmin']}>
|
||||
<div data-testid="admin-content">Admin Content</div>
|
||||
</RoleGuard>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('admin-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('QA-C5: redirects to / when user role is not in allowed list (dispatcher cannot access admin)', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
user: { id: '2', email: 'dispatch@test.com', displayName: 'Dispatcher', role: 'Dispatcher', token: 'tok' },
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<RoleGuard roles={['Admin', 'SysAdmin']}>
|
||||
<div data-testid="admin-content">Admin Content</div>
|
||||
</RoleGuard>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('admin-content')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('home-page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('QA-C5: redirects to / when user is null', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<RoleGuard roles={['Admin']}>
|
||||
<div data-testid="admin-content">Admin Content</div>
|
||||
</RoleGuard>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('admin-content')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('home-page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('QA-C5: SysAdmin can access sysadmin-only routes', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
user: { id: '3', email: 'sysadmin@test.com', displayName: 'SysAdmin', role: 'SysAdmin', token: 'tok' },
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<RoleGuard roles={['SysAdmin']}>
|
||||
<div data-testid="sysadmin-content">SysAdmin Content</div>
|
||||
</RoleGuard>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('sysadmin-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('QA-C5: Admin cannot access sysadmin-only routes', () => {
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
user: { id: '4', email: 'admin@test.com', displayName: 'Admin', role: 'Admin', token: 'tok' },
|
||||
loading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<RoleGuard roles={['SysAdmin']}>
|
||||
<div data-testid="sysadmin-content">SysAdmin Content</div>
|
||||
</RoleGuard>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('sysadmin-content')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('home-page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
1
web/src/test/setup.ts
Normal file
1
web/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom/vitest';
|
||||
12
web/vitest.config.ts
Normal file
12
web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue