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:
Adam Moussa 2026-05-27 17:31:18 -04:00
parent 2017c0379e
commit d21b1c5edb
21 changed files with 3053 additions and 17 deletions

View file

@ -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

View file

@ -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
}

View 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;
}
}

View file

@ -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);
}
}

View 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>

View file

@ -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),
};
}
}

View file

@ -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");
}
}

View file

@ -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
View file

@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = tests

View file

19
lambdas/tests/conftest.py Normal file
View 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
View 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}

View 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

View 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

View 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

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View 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);
});
});

View 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
View file

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

12
web/vitest.config.ts Normal file
View 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}'],
},
});