diff --git a/api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs b/api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs index 729fd3e..244b028 100644 --- a/api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs +++ b/api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using ProposalSystem.Infrastructure.Data; namespace ProposalSystem.Tests.Helpers; @@ -6,6 +7,7 @@ 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. +/// Transaction warnings are suppressed since InMemory provider does not support them. /// public static class DbContextFactory { @@ -13,6 +15,7 @@ public static class DbContextFactory { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}") + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .Options; var context = new ProposalDbContext(options); diff --git a/api/tests/ProposalSystem.Tests/Helpers/SqliteDbContextFactory.cs b/api/tests/ProposalSystem.Tests/Helpers/SqliteDbContextFactory.cs new file mode 100644 index 0000000..6177b97 --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Helpers/SqliteDbContextFactory.cs @@ -0,0 +1,48 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using ProposalSystem.Infrastructure.Data; + +namespace ProposalSystem.Tests.Helpers; + +/// +/// Creates SQLite-backed in-memory DbContext instances for tests that require relational +/// features (e.g., ExecuteSqlRawAsync, transactions). The SQLite connection is kept open +/// for the lifetime of the context; disposing the context closes the connection and +/// discards the in-memory database. +/// +/// Registers stub Postgres functions (hashtext, pg_advisory_xact_lock) so that +/// production SQL using these functions does not throw in the test environment. +/// +public static class SqliteDbContextFactory +{ + /// + /// Creates a new ProposalDbContext backed by a unique SQLite in-memory database. + /// The returned context owns the connection; dispose the context to clean up. + /// + public static ProposalDbContext Create() + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + // Register Postgres-specific function stubs so ExecuteSqlRawAsync calls + // like "SELECT pg_advisory_xact_lock(hashtext('...'))" succeed in SQLite. + RegisterPostgresStubs(connection); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + var context = new ProposalDbContext(options); + context.Database.EnsureCreated(); + return context; + } + + private static void RegisterPostgresStubs(SqliteConnection connection) + { + // hashtext(text) -> integer — returns a constant; only needed so SQL parses + connection.CreateFunction("hashtext", (string _) => 0); + + // pg_advisory_xact_lock(bigint) -> void — no-op in tests + connection.CreateFunction("pg_advisory_xact_lock", (long _) => 0); + } +} diff --git a/api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj b/api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj index 41c44cf..a5ca9bf 100644 --- a/api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj +++ b/api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/api/tests/ProposalSystem.Tests/Services/LineItemServiceStateGuardTests.cs b/api/tests/ProposalSystem.Tests/Services/LineItemServiceStateGuardTests.cs new file mode 100644 index 0000000..c3e068d --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Services/LineItemServiceStateGuardTests.cs @@ -0,0 +1,421 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Interfaces; +using ProposalSystem.Domain.Entities; +using ProposalSystem.Infrastructure.Services; +using ProposalSystem.Tests.Helpers; +using Xunit; + +namespace ProposalSystem.Tests.Services; + +/// +/// LineItemService state guard tests. +/// Verifies that line items cannot be created, bulk-updated, or deleted on +/// proposals in Approved or Sent status. Line item operations on InReview +/// and Revised proposals must succeed. +/// +/// Related findings: QA-C2 (state machine enforcement), API-H3 (status field exposure). +/// +public class LineItemServiceStateGuardTests : IDisposable +{ + private readonly Infrastructure.Data.ProposalDbContext _db; + private readonly IAuditService _audit; + private readonly LineItemService _sut; + private readonly Guid _userId; + + public LineItemServiceStateGuardTests() + { + _db = DbContextFactory.Create(); + _audit = Substitute.For(); + + _userId = Guid.NewGuid(); + _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 LineItemService(_db, _audit, Substitute.For>()); + } + + public void Dispose() + { + _db.Dispose(); + } + + #region CreateAsync Guards + + [Theory(DisplayName = "QA-C2: CreateAsync throws on Approved and Sent proposals")] + [InlineData(ProposalStatus.Approved)] + [InlineData(ProposalStatus.Sent)] + public async Task CreateAsync_OnLockedStatus_ThrowsInvalidOperation(ProposalStatus status) + { + // Arrange + var proposal = CreateProposal(status); + _db.Proposals.Add(proposal); + await _db.SaveChangesAsync(); + + var request = CreateLineItemRequest(); + + // Act + var act = () => _sut.CreateAsync(proposal.Id, request); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*approved/sent*"); + } + + [Theory(DisplayName = "QA-C2: CreateAsync succeeds on InReview and Revised proposals")] + [InlineData(ProposalStatus.InReview)] + [InlineData(ProposalStatus.Revised)] + public async Task CreateAsync_OnEditableStatus_Succeeds(ProposalStatus status) + { + // Arrange + var proposal = CreateProposal(status); + _db.Proposals.Add(proposal); + await _db.SaveChangesAsync(); + + var request = CreateLineItemRequest(); + + // Act + var result = await _sut.CreateAsync(proposal.Id, request); + + // Assert + result.Should().NotBeNull(); + result.Description.Should().Be(request.Description); + result.ProposalId.Should().Be(proposal.Id); + } + + [Fact(DisplayName = "QA-C2: CreateAsync throws KeyNotFoundException for nonexistent proposal")] + public async Task CreateAsync_NonexistentProposal_ThrowsKeyNotFound() + { + var act = () => _sut.CreateAsync(Guid.NewGuid(), CreateLineItemRequest()); + await act.Should().ThrowAsync(); + } + + #endregion + + #region BulkUpdateAsync Guards + + [Theory(DisplayName = "QA-C2: BulkUpdateAsync throws on Approved and Sent proposals")] + [InlineData(ProposalStatus.Approved)] + [InlineData(ProposalStatus.Sent)] + public async Task BulkUpdateAsync_OnLockedStatus_ThrowsInvalidOperation(ProposalStatus status) + { + // Arrange + var proposal = CreateProposal(status); + _db.Proposals.Add(proposal); + await _db.SaveChangesAsync(); + + var request = new BulkUpdateLineItemsRequest(new List + { + new(null, "Updated item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual), + }); + + // Act + var act = () => _sut.BulkUpdateAsync(proposal.Id, request); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*approved/sent*"); + } + + [Fact(DisplayName = "QA-C2: BulkUpdateAsync succeeds on InReview proposal")] + public async Task BulkUpdateAsync_OnInReview_Succeeds() + { + // Arrange + var proposal = CreateProposal(ProposalStatus.InReview); + _db.Proposals.Add(proposal); + await _db.SaveChangesAsync(); + + var request = new BulkUpdateLineItemsRequest(new List + { + new(null, "Item A", 2, "hours", 50m, 100m, PricingMode.UnitPrice, 1, LineItemSource.Manual), + new(null, "Item B", 1, "each", null, 200m, PricingMode.TotalPrice, 2, LineItemSource.AI), + }); + + // Act + var result = await _sut.BulkUpdateAsync(proposal.Id, request); + + // Assert + result.Should().HaveCount(2); + result.Select(r => r.Description).Should().BeEquivalentTo("Item A", "Item B"); + } + + [Fact(DisplayName = "QA-C2: BulkUpdateAsync updates proposal TotalBidAmount")] + public async Task BulkUpdateAsync_UpdatesTotalBidAmount() + { + // Arrange + var proposal = CreateProposal(ProposalStatus.InReview); + _db.Proposals.Add(proposal); + await _db.SaveChangesAsync(); + + var request = new BulkUpdateLineItemsRequest(new List + { + new(null, "Item A", 1, "each", null, 500m, PricingMode.TotalPrice, 1, LineItemSource.Manual), + new(null, "Item B", 1, "each", null, 300m, PricingMode.TotalPrice, 2, LineItemSource.Manual), + }); + + // Act + await _sut.BulkUpdateAsync(proposal.Id, request); + + // Assert + var updated = await _db.Proposals.FindAsync(proposal.Id); + updated!.TotalBidAmount.Should().Be(800m); + } + + [Fact(DisplayName = "QA-C2: BulkUpdateAsync replaces all existing line items")] + public async Task BulkUpdateAsync_ReplacesExistingItems() + { + // Arrange + var proposal = CreateProposal(ProposalStatus.InReview); + proposal.LineItems.Add(new LineItem + { + Id = Guid.NewGuid(), + ProposalId = proposal.Id, + Description = "Old item", + Quantity = 1, + Unit = "each", + TotalPrice = 999m, + PricingMode = PricingMode.TotalPrice, + Source = LineItemSource.Manual, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }); + _db.Proposals.Add(proposal); + await _db.SaveChangesAsync(); + + var request = new BulkUpdateLineItemsRequest(new List + { + new(null, "New item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual), + }); + + // Act + var result = await _sut.BulkUpdateAsync(proposal.Id, request); + + // Assert + result.Should().HaveCount(1); + result[0].Description.Should().Be("New item"); + + var dbItems = _db.LineItems.Where(li => li.ProposalId == proposal.Id).ToList(); + dbItems.Should().HaveCount(1); + dbItems[0].Description.Should().Be("New item"); + } + + [Fact(DisplayName = "QA-C2: BulkUpdateAsync throws KeyNotFoundException for nonexistent proposal")] + public async Task BulkUpdateAsync_NonexistentProposal_ThrowsKeyNotFound() + { + var request = new BulkUpdateLineItemsRequest(new List()); + var act = () => _sut.BulkUpdateAsync(Guid.NewGuid(), request); + await act.Should().ThrowAsync(); + } + + #endregion + + #region DeleteAsync Guards + + [Theory(DisplayName = "QA-C2: DeleteAsync throws on Approved and Sent proposals")] + [InlineData(ProposalStatus.Approved)] + [InlineData(ProposalStatus.Sent)] + public async Task DeleteAsync_OnLockedStatus_ThrowsInvalidOperation(ProposalStatus status) + { + // Arrange + var proposal = CreateProposal(status); + var lineItem = new LineItem + { + Id = Guid.NewGuid(), + ProposalId = proposal.Id, + Description = "Item to delete", + Quantity = 1, + Unit = "each", + TotalPrice = 100m, + PricingMode = PricingMode.TotalPrice, + Source = LineItemSource.Manual, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + proposal.LineItems.Add(lineItem); + _db.Proposals.Add(proposal); + await _db.SaveChangesAsync(); + + // Act + var act = () => _sut.DeleteAsync(proposal.Id, lineItem.Id); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*approved/sent*"); + } + + [Fact(DisplayName = "QA-C2: DeleteAsync succeeds on InReview proposal")] + public async Task DeleteAsync_OnInReview_Succeeds() + { + // Arrange + var proposal = CreateProposal(ProposalStatus.InReview); + var lineItem = new LineItem + { + Id = Guid.NewGuid(), + ProposalId = proposal.Id, + Description = "Item to delete", + Quantity = 1, + Unit = "each", + TotalPrice = 100m, + PricingMode = PricingMode.TotalPrice, + Source = LineItemSource.Manual, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + proposal.LineItems.Add(lineItem); + _db.Proposals.Add(proposal); + await _db.SaveChangesAsync(); + + // Act + await _sut.DeleteAsync(proposal.Id, lineItem.Id); + + // Assert + var deleted = await _db.LineItems.FindAsync(lineItem.Id); + deleted.Should().BeNull(); + } + + [Fact(DisplayName = "QA-C2: DeleteAsync throws KeyNotFoundException for nonexistent line item")] + public async Task DeleteAsync_NonexistentLineItem_ThrowsKeyNotFound() + { + // Arrange + var proposal = CreateProposal(ProposalStatus.InReview); + _db.Proposals.Add(proposal); + await _db.SaveChangesAsync(); + + // Act + var act = () => _sut.DeleteAsync(proposal.Id, Guid.NewGuid()); + + // Assert + await act.Should().ThrowAsync(); + } + + #endregion + + #region Audit Logging + + [Fact(DisplayName = "QA-C2: CreateAsync logs audit event on success")] + public async Task CreateAsync_LogsAuditEvent() + { + // Arrange + var proposal = CreateProposal(ProposalStatus.InReview); + _db.Proposals.Add(proposal); + await _db.SaveChangesAsync(); + + var request = CreateLineItemRequest(); + + // Act + await _sut.CreateAsync(proposal.Id, request); + + // Assert + await _audit.Received(1).LogAsync( + AuditAction.EditLineItem, + proposal.Id, + Arg.Is(s => s != null && s.Contains(request.Description)), + Arg.Any()); + } + + [Fact(DisplayName = "QA-C2: BulkUpdateAsync logs audit event on success")] + public async Task BulkUpdateAsync_LogsAuditEvent() + { + // Arrange + var proposal = CreateProposal(ProposalStatus.InReview); + _db.Proposals.Add(proposal); + await _db.SaveChangesAsync(); + + var request = new BulkUpdateLineItemsRequest(new List + { + new(null, "Item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual), + }); + + // Act + await _sut.BulkUpdateAsync(proposal.Id, request); + + // Assert — audit detail may be plain text ("Bulk update: N items") or JSON + await _audit.Received(1).LogAsync( + AuditAction.EditLineItem, + proposal.Id, + Arg.Is(s => s != null), + Arg.Any()); + } + + [Fact(DisplayName = "QA-C2: DeleteAsync logs audit event on success")] + public async Task DeleteAsync_LogsAuditEvent() + { + // Arrange + var proposal = CreateProposal(ProposalStatus.InReview); + var lineItem = new LineItem + { + Id = Guid.NewGuid(), + ProposalId = proposal.Id, + Description = "Audit test item", + Quantity = 1, + Unit = "each", + TotalPrice = 100m, + PricingMode = PricingMode.TotalPrice, + Source = LineItemSource.Manual, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + proposal.LineItems.Add(lineItem); + _db.Proposals.Add(proposal); + await _db.SaveChangesAsync(); + + // Act + await _sut.DeleteAsync(proposal.Id, lineItem.Id); + + // Assert + await _audit.Received(1).LogAsync( + AuditAction.EditLineItem, + proposal.Id, + Arg.Is(s => s != null && s.Contains("Audit test item")), + Arg.Any()); + } + + #endregion + + private Proposal CreateProposal(ProposalStatus status) + { + return new Proposal + { + Id = Guid.NewGuid(), + ProposalNumber = $"SHI-2026-{Guid.NewGuid():N}"[..16], + WorkOrderNumber = "WO-TEST", + CustomerName = "Test Customer", + CustomerAddress = "123 Test St", + ScopeOfWork = "Test scope of work", + ServiceCategory = ServiceCategory.General, + Priority = Priority.Standard, + Status = status, + Notes = "", + SubmittedById = _userId, + SubmittedAt = DateTime.UtcNow, + CurrentRevision = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + } + + private static CreateLineItemRequest CreateLineItemRequest() + { + return new CreateLineItemRequest( + Description: "HVAC duct replacement", + Quantity: 2, + Unit: "each", + UnitPrice: 250m, + TotalPrice: 500m, + PricingMode: PricingMode.UnitPrice, + SortOrder: 1, + Source: LineItemSource.Manual + ); + } +} diff --git a/api/tests/ProposalSystem.Tests/Services/ProposalNumberGeneratorTests.cs b/api/tests/ProposalSystem.Tests/Services/ProposalNumberGeneratorTests.cs new file mode 100644 index 0000000..b630521 --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Services/ProposalNumberGeneratorTests.cs @@ -0,0 +1,218 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using ProposalSystem.Domain.Entities; +using ProposalSystem.Infrastructure.Services; +using ProposalSystem.Tests.Helpers; +using Xunit; + +namespace ProposalSystem.Tests.Services; + +/// +/// Tests for ProposalNumberGenerator. +/// Verifies format (SHI-YYYY-NNNN), uniqueness, and sequence logic. +/// Uses SQLite in-memory provider because the generator calls ExecuteSqlRawAsync +/// for pg_advisory_xact_lock, which requires a relational provider (InMemory throws). +/// SQLite silently accepts the Postgres-specific SQL, allowing the sequence logic to +/// be exercised end-to-end. +/// +/// Related findings: QA-C1 (test coverage gaps). +/// +public class ProposalNumberGeneratorTests : IDisposable +{ + private readonly Infrastructure.Data.ProposalDbContext _db; + private readonly ProposalNumberGenerator _sut; + private readonly Guid _userId; + + public ProposalNumberGeneratorTests() + { + _db = SqliteDbContextFactory.Create(); + + // Create a user required by FK constraints on Proposal.SubmittedById + _userId = Guid.NewGuid(); + _db.Users.Add(new User + { + Id = _userId, + CognitoSub = $"sub-{_userId}", + Email = "test@seahavenind.com", + DisplayName = "Test User", + Role = UserRole.Dispatcher, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }); + _db.SaveChanges(); + + _sut = new ProposalNumberGenerator(_db); + } + + public void Dispose() + { + _db.Dispose(); + } + + [Fact(DisplayName = "QA-C1: GenerateAsync returns SHI-YYYY-0001 format when no prior proposals")] + public async Task GenerateAsync_NoPriorProposals_ReturnsFirstSequenceNumber() + { + // Act + var result = await _sut.GenerateAsync(); + + // Assert + var year = DateTime.UtcNow.Year; + result.Should().Be($"SHI-{year}-0001"); + } + + [Fact(DisplayName = "QA-C1: GenerateAsync format matches SHI-YYYY-NNNN pattern")] + public async Task GenerateAsync_MatchesExpectedFormat() + { + // Act + var result = await _sut.GenerateAsync(); + + // Assert + result.Should().MatchRegex(@"^SHI-\d{4}-\d{4}$"); + } + + [Fact(DisplayName = "QA-C1: GenerateAsync increments sequence from last proposal")] + public async Task GenerateAsync_WithExistingProposals_IncrementsSequence() + { + // Arrange — seed a proposal with number ending in 0042 + var year = DateTime.UtcNow.Year; + _db.Proposals.Add(CreateProposal($"SHI-{year}-0042")); + await _db.SaveChangesAsync(); + + // Act + var result = await _sut.GenerateAsync(); + + // Assert + result.Should().Be($"SHI-{year}-0043"); + } + + [Fact(DisplayName = "QA-C1: GenerateAsync skips revision proposals (containing -R)")] + public async Task GenerateAsync_IgnoresRevisionProposals() + { + // Arrange — seed a regular and a revision proposal + var year = DateTime.UtcNow.Year; + _db.Proposals.Add(CreateProposal($"SHI-{year}-0010")); + _db.Proposals.Add(CreateProposal($"SHI-{year}-0010-R2")); // revision should be ignored + await _db.SaveChangesAsync(); + + // Act + var result = await _sut.GenerateAsync(); + + // Assert — next after 0010, not after the revision + result.Should().Be($"SHI-{year}-0011"); + } + + [Fact(DisplayName = "QA-C1: GenerateAsync ignores proposals from different years")] + public async Task GenerateAsync_IgnoresDifferentYearProposals() + { + // Arrange — seed proposals from a previous year + var lastYear = DateTime.UtcNow.Year - 1; + _db.Proposals.Add(CreateProposal($"SHI-{lastYear}-0099")); + await _db.SaveChangesAsync(); + + // Act + var result = await _sut.GenerateAsync(); + + // Assert — starts at 0001 for the current year + var year = DateTime.UtcNow.Year; + result.Should().Be($"SHI-{year}-0001"); + } + + [Fact(DisplayName = "QA-C1: GenerateAsync produces unique numbers across multiple calls")] + public async Task GenerateAsync_MultipleCalls_ProducesUniqueNumbers() + { + // Act — generate several numbers, persisting each to DB between calls + var numbers = new List(); + for (int i = 0; i < 5; i++) + { + var number = await _sut.GenerateAsync(); + numbers.Add(number); + // Persist so the next call sees it + _db.Proposals.Add(CreateProposal(number)); + await _db.SaveChangesAsync(); + } + + // Assert + numbers.Should().OnlyHaveUniqueItems(); + var year = DateTime.UtcNow.Year; + numbers.Should().BeEquivalentTo(new[] + { + $"SHI-{year}-0001", + $"SHI-{year}-0002", + $"SHI-{year}-0003", + $"SHI-{year}-0004", + $"SHI-{year}-0005", + }); + } + + [Fact(DisplayName = "QA-C1: GenerateAsync zero-pads sequence to 4 digits")] + public async Task GenerateAsync_ZeroPadsToFourDigits() + { + // Arrange + var year = DateTime.UtcNow.Year; + _db.Proposals.Add(CreateProposal($"SHI-{year}-0003")); + await _db.SaveChangesAsync(); + + // Act + var result = await _sut.GenerateAsync(); + + // Assert + result.Should().EndWith("-0004"); + // Verify the sequence part is exactly 4 characters + var sequencePart = result.Split('-').Last(); + sequencePart.Should().HaveLength(4); + } + + [Fact(DisplayName = "QA-C1: GenerateAsync handles sequence above 9999 (5+ digits)")] + public async Task GenerateAsync_HighSequenceNumber_StillFormatsCorrectly() + { + // Arrange — seed a proposal at 9999 + var year = DateTime.UtcNow.Year; + _db.Proposals.Add(CreateProposal($"SHI-{year}-9999")); + await _db.SaveChangesAsync(); + + // Act + var result = await _sut.GenerateAsync(); + + // Assert — D4 format will produce 5 digits at 10000 + result.Should().Be($"SHI-{year}-10000"); + } + + [Fact(DisplayName = "QA-C1: GenerateAsync picks highest existing sequence, not last inserted")] + public async Task GenerateAsync_OutOfOrderInsertion_PicksHighest() + { + // Arrange — insert out of order + var year = DateTime.UtcNow.Year; + _db.Proposals.Add(CreateProposal($"SHI-{year}-0050")); + _db.Proposals.Add(CreateProposal($"SHI-{year}-0020")); + _db.Proposals.Add(CreateProposal($"SHI-{year}-0075")); + await _db.SaveChangesAsync(); + + // Act + var result = await _sut.GenerateAsync(); + + // Assert — should pick 75 + 1 = 76 since OrderByDescending picks the highest + result.Should().Be($"SHI-{year}-0076"); + } + + private Proposal CreateProposal(string proposalNumber) + { + return new Proposal + { + Id = Guid.NewGuid(), + ProposalNumber = proposalNumber, + WorkOrderNumber = "WO-TEST", + CustomerName = "Test Customer", + CustomerAddress = "123 Test St", + ScopeOfWork = "Test scope", + ServiceCategory = ServiceCategory.General, + Priority = Priority.Standard, + Status = ProposalStatus.InReview, + Notes = "", + SubmittedById = _userId, + SubmittedAt = DateTime.UtcNow, + CurrentRevision = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + } +} diff --git a/web/src/lib/api/__tests__/client.test.ts b/web/src/lib/api/__tests__/client.test.ts new file mode 100644 index 0000000..aba0721 --- /dev/null +++ b/web/src/lib/api/__tests__/client.test.ts @@ -0,0 +1,277 @@ +/** + * API client interceptor tests (WEB-M2, WEB-C1). + * + * Tests that: + * - Request interceptor attaches Bearer token from sessionStorage + * - Request interceptor handles missing/invalid token data gracefully + * - Response interceptor dispatches Redux logout and redirects on 401 + * - Response interceptor returns friendly error message on 403 + * - Response interceptor returns friendly error message on 404 + * - Response interceptor extracts server error detail from response body + * - Response interceptor handles network errors (no response) + */ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; +import type { InternalAxiosRequestConfig } from 'axios'; + +// vi.hoisted returns values accessible in both the hoisted mock scope and test scope. +const { interceptors, mockDispatch } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const state: Record = {}; + const dispatch = vi.fn(); + return { + interceptors: state, + mockDispatch: dispatch, + }; +}); + +// Mock the Redux store +vi.mock('../../../app/store', () => ({ + store: { + dispatch: (...args: unknown[]) => mockDispatch(...args), + getState: () => ({ auth: { user: null, isAuthenticated: false, loading: false, error: null } }), + subscribe: vi.fn(), + replaceReducer: vi.fn(), + [Symbol.observable]: vi.fn(), + }, +})); + +// Mock the authSlice logout action +vi.mock('../../../app/slices/authSlice', () => ({ + logout: () => ({ type: 'auth/logout' }), + setUser: (user: unknown) => ({ type: 'auth/setUser', payload: user }), + default: (state: unknown) => state, +})); + +// Mock axios +vi.mock('axios', () => { + const mockInstance = { + interceptors: { + request: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + use: (fulfilled: any, rejected: any) => { + interceptors.requestFulfilled = fulfilled; + interceptors.requestRejected = rejected; + return 0; + }, + }, + response: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + use: (fulfilled: any, rejected: any) => { + interceptors.responseFulfilled = fulfilled; + interceptors.responseRejected = rejected; + return 0; + }, + }, + }, + defaults: { headers: { common: {} } }, + }; + + return { + default: { + create: () => mockInstance, + }, + }; +}); + +// Mock sessionStorage +const sessionStorageData: Record = {}; +vi.stubGlobal('sessionStorage', { + getItem: vi.fn((key: string) => sessionStorageData[key] ?? null), + setItem: vi.fn((key: string, value: string) => { sessionStorageData[key] = value; }), + removeItem: vi.fn((key: string) => { delete sessionStorageData[key]; }), + clear: vi.fn(() => { Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]); }), + get length() { return Object.keys(sessionStorageData).length; }, + key: vi.fn((index: number) => Object.keys(sessionStorageData)[index] ?? null), +}); + +// Mock window.location +const mockLocation = { href: '' }; +Object.defineProperty(window, 'location', { + value: mockLocation, + writable: true, + configurable: true, +}); + +describe('API client interceptors', () => { + beforeAll(async () => { + // Dynamically import the module under test after all mocks are set up + await import('../client'); + }); + + beforeEach(() => { + vi.clearAllMocks(); + Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]); + mockLocation.href = ''; + }); + + describe('request interceptor', () => { + it('WEB-C1: attaches Bearer token from sessionStorage when valid token data exists', () => { + const tokenData = JSON.stringify({ token: 'my-jwt-token-123' }); + sessionStorageData['proposal_system_token'] = tokenData; + + const config = { + headers: {} as Record, + } as unknown as InternalAxiosRequestConfig; + + const result = interceptors.requestFulfilled(config); + + expect(result.headers.Authorization).toBe('Bearer my-jwt-token-123'); + }); + + it('WEB-C1: does not attach Authorization header when no token in sessionStorage', () => { + const config = { + headers: {} as Record, + } as unknown as InternalAxiosRequestConfig; + + const result = interceptors.requestFulfilled(config); + + expect(result.headers.Authorization).toBeUndefined(); + }); + + it('WEB-C1: handles malformed JSON in sessionStorage gracefully', () => { + sessionStorageData['proposal_system_token'] = 'not-valid-json{{{'; + + const config = { + headers: {} as Record, + } as unknown as InternalAxiosRequestConfig; + + const result = interceptors.requestFulfilled(config); + + expect(result.headers.Authorization).toBeUndefined(); + }); + + it('WEB-C1: does not attach header when token field is missing in parsed data', () => { + sessionStorageData['proposal_system_token'] = JSON.stringify({ email: 'user@test.com' }); + + const config = { + headers: {} as Record, + } as unknown as InternalAxiosRequestConfig; + + const result = interceptors.requestFulfilled(config); + + expect(result.headers.Authorization).toBeUndefined(); + }); + + it('WEB-C1: request error rejection propagates the error', async () => { + const error = new Error('request setup failed'); + const promise = interceptors.requestRejected(error); + + await expect(promise).rejects.toEqual(error); + }); + }); + + describe('response interceptor', () => { + it('WEB-M2: 401 response dispatches Redux logout and redirects to /login', async () => { + const error = { + response: { + status: 401, + data: {}, + }, + request: {}, + }; + + const promise = interceptors.responseRejected(error); + + await expect(promise).rejects.toThrow('Session expired. Please log in again.'); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'auth/logout' }); + expect(mockLocation.href).toBe('/login'); + }); + + it('WEB-M2: 403 response returns permission error without dispatching logout', async () => { + const error = { + response: { + status: 403, + data: {}, + }, + request: {}, + }; + + const promise = interceptors.responseRejected(error); + + await expect(promise).rejects.toThrow('You do not have permission to perform this action.'); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('404 response returns resource-not-found error', async () => { + const error = { + response: { + status: 404, + data: {}, + }, + request: {}, + }; + + const promise = interceptors.responseRejected(error); + + await expect(promise).rejects.toThrow('The requested resource was not found.'); + }); + + it('extracts detail message from server error response', async () => { + const error = { + response: { + status: 422, + data: { detail: 'Validation failed: scope too short' }, + }, + request: {}, + }; + + const promise = interceptors.responseRejected(error); + + await expect(promise).rejects.toThrow('Validation failed: scope too short'); + }); + + it('extracts title message when detail is absent', async () => { + const error = { + response: { + status: 500, + data: { title: 'Internal Server Error' }, + }, + request: {}, + }; + + const promise = interceptors.responseRejected(error); + + await expect(promise).rejects.toThrow('Internal Server Error'); + }); + + it('falls back to generic message when no detail/title/message in response', async () => { + const error = { + response: { + status: 500, + data: {}, + }, + request: {}, + }; + + const promise = interceptors.responseRejected(error); + + await expect(promise).rejects.toThrow('An error occurred'); + }); + + it('handles network error (no response from server)', async () => { + const error = { + request: {}, + }; + + const promise = interceptors.responseRejected(error); + + await expect(promise).rejects.toThrow('No response from server. Please check your connection.'); + }); + + it('passes through non-axios errors unchanged', async () => { + const error = new Error('Something unexpected'); + + const promise = interceptors.responseRejected(error); + + await expect(promise).rejects.toEqual(error); + }); + + it('response success passes through unchanged', () => { + const response = { data: { id: 1 }, status: 200 }; + + const result = interceptors.responseFulfilled(response); + + expect(result).toBe(response); + }); + }); +});