diff --git a/api/ProposalSystem.sln b/api/ProposalSystem.sln index f34f266..baad3fb 100644 --- a/api/ProposalSystem.sln +++ b/api/ProposalSystem.sln @@ -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 diff --git a/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs b/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs new file mode 100644 index 0000000..b6e4a41 --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs @@ -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; + +/// +/// 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. +/// +public class AuthorizationAttributeTests +{ + #region Controller-Level Authorization + + [Fact(DisplayName = "QA-C3: ProposalsController requires authentication")] + public void ProposalsController_HasAuthorizeAttribute() + { + typeof(ProposalsController) + .GetCustomAttribute() + .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(); + 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() + .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(); + 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(); + // 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(); + 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(); + // 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(); + 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(); + 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(); + 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(); + attr.Should().NotBeNull( + $"{controllerType.Name} must have [Authorize] attribute to prevent unauthenticated access"); + } + + #endregion +} diff --git a/api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs b/api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs new file mode 100644 index 0000000..729fd3e --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using ProposalSystem.Infrastructure.Data; + +namespace ProposalSystem.Tests.Helpers; + +/// +/// Creates isolated in-memory DbContext instances for unit tests. +/// Each call produces a uniquely-named database so tests don't leak state. +/// +public static class DbContextFactory +{ + public static ProposalDbContext Create() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}") + .Options; + + var context = new ProposalDbContext(options); + context.Database.EnsureCreated(); + return context; + } +} diff --git a/api/tests/ProposalSystem.Tests/Middleware/InternalApiKeyMiddlewareTests.cs b/api/tests/ProposalSystem.Tests/Middleware/InternalApiKeyMiddlewareTests.cs new file mode 100644 index 0000000..ff889ab --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Middleware/InternalApiKeyMiddlewareTests.cs @@ -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; + +/// +/// 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. +/// +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(); + } + + /// + /// 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. + /// + [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 nextCalled) CreateMiddleware(string? configuredKey) + { + var wasNextCalled = false; + RequestDelegate next = _ => + { + wasNextCalled = true; + return Task.CompletedTask; + }; + + var configData = new Dictionary(); + if (configuredKey != null) + { + configData["INTERNAL_API_KEY"] = configuredKey; + } + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configData) + .Build(); + + var logger = Substitute.For>(); + + var middleware = new InternalApiKeyMiddleware(next, configuration, logger); + var context = new DefaultHttpContext(); + + return (middleware, context, () => wasNextCalled); + } +} diff --git a/api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj b/api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj new file mode 100644 index 0000000..41c44cf --- /dev/null +++ b/api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs b/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs new file mode 100644 index 0000000..0942811 --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs @@ -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; + +/// +/// 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). +/// +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(); + _audit = Substitute.For(); + _jobPublisher = Substitute.For(); + _numberGenerator = Substitute.For(); + + 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() + .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() + .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() + .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() + .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() + .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() + .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() + .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(); + await sendAct.Should().ThrowAsync(); + await reviseAct.Should().ThrowAsync(); + } + + #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(), Arg.Any()); + } + + [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(), Arg.Any()); + } + + #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), + }; + } +} diff --git a/api/tests/ProposalSystem.Tests/Validators/CreateLineItemValidatorTests.cs b/api/tests/ProposalSystem.Tests/Validators/CreateLineItemValidatorTests.cs new file mode 100644 index 0000000..f13fe4c --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Validators/CreateLineItemValidatorTests.cs @@ -0,0 +1,129 @@ +using FluentAssertions; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Validators; +using ProposalSystem.Domain.Entities; +using Xunit; + +namespace ProposalSystem.Tests.Validators; + +/// +/// Tests for CreateLineItemValidator — validates line item payloads. +/// +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"); + } +} diff --git a/api/tests/ProposalSystem.Tests/Validators/CreateProposalValidatorTests.cs b/api/tests/ProposalSystem.Tests/Validators/CreateProposalValidatorTests.cs new file mode 100644 index 0000000..b7ac38b --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Validators/CreateProposalValidatorTests.cs @@ -0,0 +1,181 @@ +using FluentAssertions; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Validators; +using ProposalSystem.Domain.Entities; +using Xunit; + +namespace ProposalSystem.Tests.Validators; + +/// +/// Tests for CreateProposalValidator — validates request payloads before +/// they hit the service layer. +/// +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"); + } +} diff --git a/lambdas/pytest.ini b/lambdas/pytest.ini new file mode 100644 index 0000000..85cb3c1 --- /dev/null +++ b/lambdas/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = tests diff --git a/lambdas/tests/__init__.py b/lambdas/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lambdas/tests/conftest.py b/lambdas/tests/conftest.py new file mode 100644 index 0000000..ee9c9e1 --- /dev/null +++ b/lambdas/tests/conftest.py @@ -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") diff --git a/lambdas/tests/helpers.py b/lambdas/tests/helpers.py new file mode 100644 index 0000000..1e5c91c --- /dev/null +++ b/lambdas/tests/helpers.py @@ -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} diff --git a/lambdas/tests/requirements-test.txt b/lambdas/tests/requirements-test.txt new file mode 100644 index 0000000..a78dc5a --- /dev/null +++ b/lambdas/tests/requirements-test.txt @@ -0,0 +1,4 @@ +pytest>=8.0.0 +pytest-mock>=3.14.0 +moto[s3,secretsmanager,sqs]>=5.0.0 +httpx>=0.27.0 diff --git a/lambdas/tests/test_pdf_generate.py b/lambdas/tests/test_pdf_generate.py new file mode 100644 index 0000000..22060ba --- /dev/null +++ b/lambdas/tests/test_pdf_generate.py @@ -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 diff --git a/lambdas/tests/test_suggestions.py b/lambdas/tests/test_suggestions.py new file mode 100644 index 0000000..35c28a5 --- /dev/null +++ b/lambdas/tests/test_suggestions.py @@ -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 diff --git a/web/package-lock.json b/web/package-lock.json index 7e14d26..0676c6e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,7 +13,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", @@ -22,13 +22,76 @@ "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" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -317,6 +380,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -905,6 +1121,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1590,9 +1824,9 @@ "license": "MIT" }, "node_modules/@tanstack/query-core": { - "version": "5.100.13", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.13.tgz", - "integrity": "sha512-mlKVKMTzZWGTKAC1CKOgt7axAjJ921emkEvYIp27I/PdP1yEYL/BteLY8iK35gn8hoYeKB4mgJ/ve3lrDI6/Fw==", + "version": "5.100.11", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.11.tgz", + "integrity": "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==", "license": "MIT", "funding": { "type": "github", @@ -1600,12 +1834,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.100.13", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.13.tgz", - "integrity": "sha512-HSBr8CycQEAoXsJR7KNDawBnINJEJ96Eme8oE0hCXjyodE2I97vg3IDzDJBDu18LsbzpVVJcKo80eqLfVCykxw==", + "version": "5.100.11", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.11.tgz", + "integrity": "sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.100.13" + "@tanstack/query-core": "5.100.11" }, "funding": { "type": "github", @@ -1615,6 +1849,104 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1660,6 +1992,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1680,9 +2030,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1734,6 +2084,126 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -1746,6 +2216,51 @@ "node": ">= 6.0.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1792,6 +2307,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1869,6 +2394,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1934,12 +2469,47 @@ "node": ">= 6" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1957,6 +2527,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1966,6 +2543,24 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -1997,6 +2592,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -2024,6 +2632,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2115,6 +2730,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2312,6 +2947,19 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2351,6 +2999,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2372,12 +3030,70 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2437,6 +3153,27 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2446,6 +3183,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2467,6 +3211,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2508,6 +3262,17 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2538,6 +3303,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2553,6 +3331,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2601,6 +3386,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -2627,6 +3436,16 @@ "node": ">=10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -2754,6 +3573,20 @@ "react-dom": ">=16.6.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -2769,6 +3602,16 @@ "redux": "^5.0.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", @@ -2850,6 +3693,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2872,6 +3728,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -2891,6 +3754,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -2909,6 +3799,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -2926,6 +3840,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz", + "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.0" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.0.tgz", + "integrity": "sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -2940,6 +3910,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3055,6 +4035,178 @@ } } }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 73a596f..ec6ebd5 100644 --- a/web/package.json +++ b/web/package.json @@ -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" } } diff --git a/web/src/app/__tests__/authSlice.test.ts b/web/src/app/__tests__/authSlice.test.ts new file mode 100644 index 0000000..fb3d018 --- /dev/null +++ b/web/src/app/__tests__/authSlice.test.ts @@ -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 = {}; +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); + }); +}); diff --git a/web/src/components/__tests__/ProtectedRoute.test.tsx b/web/src/components/__tests__/ProtectedRoute.test.tsx new file mode 100644 index 0000000..16a6924 --- /dev/null +++ b/web/src/components/__tests__/ProtectedRoute.test.tsx @@ -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( + + + Login Page} /> + Home Page} /> + + + , + ); +} + +describe('ProtectedRoute', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('QA-C5: renders children when user is authenticated', () => { + mockUseAuth.mockReturnValue({ isAuthenticated: true, user: null, loading: false }); + + renderWithRouter( + +
Protected Content
+
, + ); + + 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( + +
Protected Content
+
, + ); + + 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( + +
Admin Content
+
, + ); + + 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( + +
Admin Content
+
, + ); + + 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( + +
Admin Content
+
, + ); + + 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( + +
SysAdmin Content
+
, + ); + + 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( + +
SysAdmin Content
+
, + ); + + expect(screen.queryByTestId('sysadmin-content')).not.toBeInTheDocument(); + expect(screen.getByTestId('home-page')).toBeInTheDocument(); + }); +}); diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..bb02c60 --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..4f6d395 --- /dev/null +++ b/web/vitest.config.ts @@ -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}'], + }, +});