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