using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Repositories; using PowderCoating.Infrastructure.Services; using Xunit; namespace PowderCoating.UnitTests; public class SubscriptionServiceTests { [Fact] public async Task GetUserCountAsync_PrefersCompanyOverrideOverPlanDefault() { await using var context = CreateContext(); SeedCompanyAndPlan(context, companyId: 7, plan: 1, maxUsers: 3); var company = context.Companies.Local.Single(c => c.Id == 7); company.MaxUsersOverride = 7; context.Users.AddRange( new ApplicationUser { Id = "u1", CompanyId = 7, UserName = "u1", Email = "u1@example.com", FirstName = "A", LastName = "One", IsActive = true }, new ApplicationUser { Id = "u2", CompanyId = 7, UserName = "u2", Email = "u2@example.com", FirstName = "B", LastName = "Two", IsActive = true }); await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var (used, max) = await service.GetUserCountAsync(7); Assert.Equal(2, used); Assert.Equal(7, max); } [Fact] public async Task GetJobCountAsync_ExcludesTerminalStatuses() { await using var context = CreateContext(); SeedCompanyAndPlan(context, companyId: 8, plan: 2, maxActiveJobs: 50); SeedJobStatuses(context, 8); context.Jobs.AddRange( new Job { Id = 1, CompanyId = 8, JobNumber = "JOB-1", CustomerId = 1, Description = "Active", JobStatusId = 1, JobPriorityId = 1 }, new Job { Id = 2, CompanyId = 8, JobNumber = "JOB-2", CustomerId = 1, Description = "Done", JobStatusId = 2, JobPriorityId = 1 }, new Job { Id = 3, CompanyId = 8, JobNumber = "JOB-3", CustomerId = 1, Description = "Delivered", JobStatusId = 3, JobPriorityId = 1 }); await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var (used, max) = await service.GetJobCountAsync(8); Assert.Equal(1, used); Assert.Equal(50, max); } [Fact] public async Task GetQuoteCountAsync_CountsOnlyCurrentMonth() { await using var context = CreateContext(); SeedCompanyAndPlan(context, companyId: 9, plan: 3, maxQuotes: 5); var currentQuote = new Quote { Id = 1, CompanyId = 9, QuoteNumber = "Q-001", QuoteStatusId = 1 }; var oldQuote = new Quote { Id = 2, CompanyId = 9, QuoteNumber = "Q-OLD", QuoteStatusId = 1 }; context.Quotes.AddRange(currentQuote, oldQuote); await context.SaveChangesAsync(); oldQuote.CreatedAt = DateTime.UtcNow.AddMonths(-1); await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var (used, max) = await service.GetQuoteCountAsync(9); Assert.Equal(1, used); Assert.Equal(5, max); } [Fact] public async Task CanAddCustomerAsync_CompedCompany_BypassesPlanLimits() { await using var context = CreateContext(); SeedCompanyAndPlan(context, companyId: 10, plan: 4, maxCustomers: 0); var company = await context.Companies.FindAsync(10); company!.IsComped = true; context.Customers.Add(new Customer { Id = 1, CompanyId = 10, CompanyName = "Customer A" }); await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var allowed = await service.CanAddCustomerAsync(10); Assert.True(allowed); } [Fact] public async Task CanUseAiPhotoQuoteAsync_RequiresFeatureEnabledAndQuotaAvailable() { await using var context = CreateContext(); SeedCompanyAndPlan(context, companyId: 11, plan: 5, maxAiPhotoQuotesPerMonth: 2, allowAiPhotoQuotes: true); context.AiItemPredictions.Add(new AiItemPrediction { Id = 1, CompanyId = 11, CreatedAt = DateTime.UtcNow.AddDays(-1) }); await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var allowed = await service.CanUseAiPhotoQuoteAsync(11); Assert.True(allowed); } [Fact] public async Task CanUseAiPhotoQuoteAsync_ReturnsFalse_WhenPlanDisablesFeature() { await using var context = CreateContext(); SeedCompanyAndPlan(context, companyId: 12, plan: 6, maxAiPhotoQuotesPerMonth: 10, allowAiPhotoQuotes: false); await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var allowed = await service.CanUseAiPhotoQuoteAsync(12); Assert.False(allowed); } [Fact] public async Task GetStatusAsync_ReturnsGracePeriod_WhenSubscriptionRecentlyExpired() { await using var context = CreateContext(); context.Companies.Add(new Company { Id = 20, CompanyId = 20, CompanyName = "Grace Co", PrimaryContactName = "Owner", PrimaryContactEmail = "grace@example.com", SubscriptionStatus = SubscriptionStatus.Active, SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-5), IsActive = true }); await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var status = await service.GetStatusAsync(20); Assert.Equal(SubscriptionStatus.GracePeriod, status); } [Fact] public async Task GetStatusAsync_ReturnsExpired_WhenPastGraceWindow() { await using var context = CreateContext(); context.Companies.Add(new Company { Id = 21, CompanyId = 21, CompanyName = "Expired Co", PrimaryContactName = "Owner", PrimaryContactEmail = "expired@example.com", SubscriptionStatus = SubscriptionStatus.Active, SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-15), IsActive = true }); await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var status = await service.GetStatusAsync(21); Assert.Equal(SubscriptionStatus.Expired, status); } [Fact] public async Task GetStatusAsync_ReturnsActive_ForCompedCompanyEvenWhenExpired() { await using var context = CreateContext(); context.Companies.Add(new Company { Id = 22, CompanyId = 22, CompanyName = "Comped Co", PrimaryContactName = "Owner", PrimaryContactEmail = "comped@example.com", SubscriptionStatus = SubscriptionStatus.Active, SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-30), IsActive = true, IsComped = true }); await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var status = await service.GetStatusAsync(22); Assert.Equal(SubscriptionStatus.Active, status); } [Fact] public async Task IsAiInventoryAssistEnabledAsync_RequiresCompanyToggle() { await using var context = CreateContext(); SeedCompanyAndPlan(context, companyId: 13, plan: 7, allowAiInventoryAssist: true); var company = await context.Companies.FindAsync(13); company!.AiInventoryAssistEnabled = false; await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var enabled = await service.IsAiInventoryAssistEnabledAsync(13); Assert.False(enabled); } [Fact] public async Task GetJobPhotoCountAsync_ExcludesAiAnalysisPhotos() { await using var context = CreateContext(); SeedCompanyAndPlan(context, companyId: 14, plan: 8, maxJobPhotos: 5); context.JobPhotos.AddRange( new JobPhoto { Id = 1, CompanyId = 14, JobId = 100, FilePath = "jobs/100/1.jpg", FileName = "1.jpg", FileSize = 100, ContentType = "image/jpeg", UploadedById = "u1" }, new JobPhoto { Id = 2, CompanyId = 14, JobId = 100, FilePath = "jobs/100/2.jpg", FileName = "2.jpg", FileSize = 100, ContentType = "image/jpeg", UploadedById = "u1", IsAiAnalysisPhoto = true }); await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var (used, max) = await service.GetJobPhotoCountAsync(14, 100); Assert.Equal(1, used); Assert.Equal(5, max); } [Fact] public async Task GetQuotePhotoCountAsync_ExcludesAiAnalysisPhotos() { await using var context = CreateContext(); SeedCompanyAndPlan(context, companyId: 15, plan: 9, maxQuotePhotos: 4); context.QuotePhotos.AddRange( new QuotePhoto { Id = 1, CompanyId = 15, QuoteId = 200, TempId = "temp-1", FilePath = "quotes/200/1.jpg", FileName = "1.jpg", FileSize = 100, ContentType = "image/jpeg", IsAiAnalysisPhoto = false }, new QuotePhoto { Id = 2, CompanyId = 15, QuoteId = 200, TempId = "temp-2", FilePath = "quotes/200/2.jpg", FileName = "2.jpg", FileSize = 100, ContentType = "image/jpeg", IsAiAnalysisPhoto = true }); await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var (used, max) = await service.GetQuotePhotoCountAsync(15, 200); Assert.Equal(1, used); Assert.Equal(4, max); } [Fact] public async Task CanUseAiPhotoQuoteAsync_ReturnsFalse_WhenUsageEqualsLimit() { await using var context = CreateContext(); SeedCompanyAndPlan(context, companyId: 16, plan: 10, maxAiPhotoQuotesPerMonth: 2, allowAiPhotoQuotes: true); context.AiItemPredictions.AddRange( new AiItemPrediction { Id = 1, CompanyId = 16, CreatedAt = DateTime.UtcNow.AddDays(-1) }, new AiItemPrediction { Id = 2, CompanyId = 16, CreatedAt = DateTime.UtcNow.AddDays(-2) }); await context.SaveChangesAsync(); var service = new SubscriptionService(new UnitOfWork(context), context); var allowed = await service.CanUseAiPhotoQuoteAsync(16); Assert.False(allowed); } private static ApplicationDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } private static void SeedCompanyAndPlan( ApplicationDbContext context, int companyId, int plan, int maxUsers = -1, int maxActiveJobs = -1, int maxCustomers = -1, int maxQuotes = -1, int maxAiPhotoQuotesPerMonth = -1, int maxJobPhotos = -1, int maxQuotePhotos = -1, bool allowAiPhotoQuotes = true, bool allowAiInventoryAssist = true) { context.Companies.Add(new Company { Id = companyId, CompanyId = companyId, CompanyName = $"Company {companyId}", PrimaryContactName = "Owner", PrimaryContactEmail = $"owner{companyId}@example.com", SubscriptionPlan = plan, SubscriptionStatus = SubscriptionStatus.Active, IsActive = true }); context.SubscriptionPlanConfigs.Add(new SubscriptionPlanConfig { Id = companyId, CompanyId = 0, Plan = plan, DisplayName = $"Plan {plan}", IsActive = true, MaxUsers = maxUsers, MaxActiveJobs = maxActiveJobs, MaxCustomers = maxCustomers, MaxQuotes = maxQuotes, MaxJobPhotos = maxJobPhotos, MaxQuotePhotos = maxQuotePhotos, MaxAiPhotoQuotesPerMonth = maxAiPhotoQuotesPerMonth, AllowAiPhotoQuotes = allowAiPhotoQuotes, AllowAiInventoryAssist = allowAiInventoryAssist }); context.JobPriorityLookups.Add(new JobPriorityLookup { Id = companyId, CompanyId = companyId, PriorityCode = "NORMAL", DisplayName = "Normal", DisplayOrder = 1 }); } private static void SeedJobStatuses(ApplicationDbContext context, int companyId) { context.JobStatusLookups.AddRange( new JobStatusLookup { Id = 1, CompanyId = companyId, StatusCode = "Pending", DisplayName = "Pending", DisplayOrder = 1, IsTerminalStatus = false }, new JobStatusLookup { Id = 2, CompanyId = companyId, StatusCode = "Completed", DisplayName = "Completed", DisplayOrder = 2, IsTerminalStatus = true }, new JobStatusLookup { Id = 3, CompanyId = companyId, StatusCode = "Delivered", DisplayName = "Delivered", DisplayOrder = 3, IsTerminalStatus = true }); } }