Files
PowderCoatingLogix/tests/PowderCoating.UnitTests/SubscriptionServiceTests.cs
T
spouliot 4c070e7487 Fix SubscriptionServiceTests: add IPlatformSettingsService stub
Tests broke when SubscriptionService gained the platformSettings
constructor parameter in the previous session. Add NullPlatformSettingsService
stub and pass it to all 13 test instantiations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:15:03 -04:00

424 lines
15 KiB
C#

using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
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, new NullPlatformSettingsService());
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, new NullPlatformSettingsService());
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, new NullPlatformSettingsService());
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, new NullPlatformSettingsService());
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, new NullPlatformSettingsService());
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, new NullPlatformSettingsService());
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, new NullPlatformSettingsService());
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, new NullPlatformSettingsService());
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, new NullPlatformSettingsService());
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, new NullPlatformSettingsService());
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, new NullPlatformSettingsService());
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, new NullPlatformSettingsService());
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, new NullPlatformSettingsService());
var allowed = await service.CanUseAiPhotoQuoteAsync(16);
Assert.False(allowed);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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
});
}
}
/// <summary>No-op stub for IPlatformSettingsService — returns defaults for all queries.</summary>
file sealed class NullPlatformSettingsService : IPlatformSettingsService
{
public Task<string?> GetAsync(string key) => Task.FromResult<string?>(null);
public Task<bool> GetBoolAsync(string key, bool defaultValue = false) => Task.FromResult(defaultValue);
public Task<int> GetIntAsync(string key, int defaultValue) => Task.FromResult(defaultValue);
public Task SetAsync(string key, string? value, string? updatedBy = null) => Task.CompletedTask;
public Task<IReadOnlyList<PlatformSetting>> GetAllAsync() => Task.FromResult<IReadOnlyList<PlatformSetting>>(Array.Empty<PlatformSetting>().ToList());
}