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