mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 21:43:14 +00:00
- 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.
218 lines
7.4 KiB
C#
218 lines
7.4 KiB
C#
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,
|
|
};
|
|
}
|
|
}
|