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, }; } }