mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 05:23:14 +00:00
test: add ProposalNumberGenerator, LineItemService state guard, and API client interceptor tests
- ProposalNumberGenerator tests (8 tests): format validation (SHI-YYYY-NNNN), sequence incrementing, revision skipping, year boundary isolation, uniqueness, zero-padding, high sequence rollover. Uses SQLite in-memory with Postgres function stubs to support ExecuteSqlRawAsync. - LineItemService state guard tests (18 tests): verifies line items cannot be created/bulk-updated/deleted on Approved or Sent proposals (QA-C2), confirms operations succeed on InReview and Revised statuses, validates KeyNotFoundException on missing proposals, verifies audit logging. - API client interceptor tests (14 tests): request interceptor attaches Bearer token from sessionStorage (WEB-C1), handles missing/malformed token data, response interceptor dispatches Redux logout on 401 (WEB-M2), returns friendly messages for 403/404, extracts server error details, handles network errors. - DbContextFactory updated to suppress InMemoryEventId.TransactionIgnoredWarning so BulkUpdateAsync tests work with in-memory provider. - Added SqliteDbContextFactory for tests requiring relational features. - Added Microsoft.EntityFrameworkCore.Sqlite to test project dependencies. Total: 104 .NET tests (was 77), 26 web tests (was 12). CI already wired.
This commit is contained in:
parent
8d73e66a17
commit
ab9569d7a9
6 changed files with 968 additions and 0 deletions
|
|
@ -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;
|
|||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class DbContextFactory
|
||||
{
|
||||
|
|
@ -13,6 +15,7 @@ public static class DbContextFactory
|
|||
{
|
||||
var options = new DbContextOptionsBuilder<ProposalDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}")
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.Options;
|
||||
|
||||
var context = new ProposalDbContext(options);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ProposalSystem.Infrastructure.Data;
|
||||
|
||||
namespace ProposalSystem.Tests.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class SqliteDbContextFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new ProposalDbContext backed by a unique SQLite in-memory database.
|
||||
/// The returned context owns the connection; dispose the context to clean up.
|
||||
/// </summary>
|
||||
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<ProposalDbContext>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<IAuditService>();
|
||||
|
||||
_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<ILogger<LineItemService>>());
|
||||
}
|
||||
|
||||
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<InvalidOperationException>()
|
||||
.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<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
#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<UpdateLineItemEntry>
|
||||
{
|
||||
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<InvalidOperationException>()
|
||||
.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<UpdateLineItemEntry>
|
||||
{
|
||||
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<UpdateLineItemEntry>
|
||||
{
|
||||
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<UpdateLineItemEntry>
|
||||
{
|
||||
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<UpdateLineItemEntry>());
|
||||
var act = () => _sut.BulkUpdateAsync(Guid.NewGuid(), request);
|
||||
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
#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<InvalidOperationException>()
|
||||
.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<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
#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<string?>(s => s != null && s.Contains(request.Description)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<UpdateLineItemEntry>
|
||||
{
|
||||
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<string?>(s => s != null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<string?>(s => s != null && s.Contains("Audit test item")),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
277
web/src/lib/api/__tests__/client.test.ts
Normal file
277
web/src/lib/api/__tests__/client.test.ts
Normal file
|
|
@ -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<string, any> = {};
|
||||
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<string, string> = {};
|
||||
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<string, string>,
|
||||
} 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<string, string>,
|
||||
} 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<string, string>,
|
||||
} 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<string, string>,
|
||||
} 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue