Files
spouliot 711cd01cd3 Add CRM features: Outstanding Pickups, Customer Notes, Clone Job, Preferred Powders
- Outstanding Pickups card on Customer Details shows jobs awaiting pickup with age badges
- Customer Notes log: inline add/delete notes with important flag, AJAX-backed
- Clone Job action on Jobs controller; Repeat Last Job button on Customer Details quick actions
- Preferred Powders per customer: typeahead inventory search, AJAX add/remove
- CustomerPreferredPowder entity + migration; unit tests for CRM stats/timeline logic
- Fix EF Core concurrency bug: parallel Task.WhenAll FindAsync replaced with sequential awaits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:59:32 -04:00

379 lines
16 KiB
C#

using AutoMapper;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.Customer;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
using System.Security.Claims;
namespace PowderCoating.UnitTests;
public class CustomersControllerCrmTests
{
// ── Details — guard ───────────────────────────────────────────────────
[Fact]
public async Task Details_WhenCustomerNotFound_ReturnsNotFound()
{
await using var context = CreateContext();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 999);
Assert.IsType<NotFoundResult>(result);
}
// ── Details — zero-history customer ───────────────────────────────────
[Fact]
public async Task Details_WithNoHistory_ProducesZeroStats()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(0, stats.TotalJobs);
Assert.Equal(0, stats.TotalQuotes);
Assert.Equal(0m, stats.TotalRevenue);
Assert.Equal(0m, stats.AverageJobValue); // no divide-by-zero
Assert.Null(stats.DaysSinceLastJob);
}
[Fact]
public async Task Details_WithNoHistory_DoesNotRenderTimeline()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var timeline = view.ViewData["Timeline"] as List<CustomerTimelineEventDto>;
Assert.NotNull(timeline);
Assert.Empty(timeline);
}
// ── Details — stats calculation ───────────────────────────────────────
[Fact]
public async Task Details_WithJobsAndInvoices_CalculatesStatsCorrectly()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var activeStatus = SeedJobStatus(context, id: 1, isTerminal: false);
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: activeStatus.Id, finalPrice: 300m);
SeedJob(context, id: 2, customerId: 1, companyId: 1, statusId: activeStatus.Id, finalPrice: 700m);
SeedInvoice(context, id: 1, customerId: 1, companyId: 1, total: 300m, amountPaid: 300m, status: InvoiceStatus.Paid);
SeedInvoice(context, id: 2, customerId: 1, companyId: 1, total: 700m, amountPaid: 400m, status: InvoiceStatus.PartiallyPaid);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(2, stats.TotalJobs);
Assert.Equal(2, stats.ActiveJobs);
Assert.Equal(2, stats.TotalInvoices);
Assert.Equal(1000m, stats.TotalRevenue);
Assert.Equal(700m, stats.TotalCollected);
Assert.Equal(500m, stats.AverageJobValue);
}
// ── Details — voided invoices excluded ────────────────────────────────
[Fact]
public async Task Details_VoidedInvoicesExcludedFromRevenueAndCollected()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var status = SeedJobStatus(context, id: 1, isTerminal: false);
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: status.Id, finalPrice: 500m);
SeedInvoice(context, id: 1, customerId: 1, companyId: 1, total: 500m, amountPaid: 500m, status: InvoiceStatus.Paid);
SeedInvoice(context, id: 2, customerId: 1, companyId: 1, total: 999m, amountPaid: 0m, status: InvoiceStatus.Voided);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(500m, stats.TotalRevenue); // voided invoice excluded
Assert.Equal(500m, stats.TotalCollected); // voided invoice excluded
Assert.Equal(2, stats.TotalInvoices); // count includes voided (informational)
}
// ── Details — active-job count excludes terminal statuses ─────────────
[Fact]
public async Task Details_ActiveJobsExcludesTerminalStatuses()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var active = SeedJobStatus(context, id: 1, isTerminal: false);
var completed = SeedJobStatus(context, id: 2, isTerminal: true);
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: active.Id, finalPrice: 100m);
SeedJob(context, id: 2, customerId: 1, companyId: 1, statusId: completed.Id, finalPrice: 200m);
SeedJob(context, id: 3, customerId: 1, companyId: 1, statusId: completed.Id, finalPrice: 300m);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(3, stats.TotalJobs);
Assert.Equal(1, stats.ActiveJobs);
}
// ── Details — timeline cap and sort ───────────────────────────────────
[Fact]
public async Task Details_TimelineCappedAt15Events()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var status = SeedJobStatus(context, id: 1, isTerminal: false);
for (int i = 1; i <= 18; i++)
{
context.Jobs.Add(new Job
{
Id = i,
CompanyId = 1,
CustomerId = 1,
JobStatusId = status.Id,
JobNumber = $"JOB-0001-{i:D4}",
Description = $"Job {i}",
FinalPrice = 100m,
CreatedAt = new DateTime(2026, 1, i, 0, 0, 0, DateTimeKind.Utc)
});
}
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var timeline = view.ViewData["Timeline"] as List<CustomerTimelineEventDto>;
Assert.NotNull(timeline);
Assert.Equal(15, timeline.Count);
}
[Fact]
public async Task Details_TimelineIsSortedNewestFirst()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
// Use Invoice.InvoiceDate for timeline dates — SaveChangesAsync stamps CreatedAt but
// does not touch InvoiceDate, so we can seed distinct values that survive the save.
SeedInvoice(context, id: 1, customerId: 1, companyId: 1, total: 100m, amountPaid: 100m,
status: InvoiceStatus.Paid, invoiceDate: new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc));
SeedInvoice(context, id: 2, customerId: 1, companyId: 1, total: 200m, amountPaid: 0m,
status: InvoiceStatus.Sent, invoiceDate: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
SeedInvoice(context, id: 3, customerId: 1, companyId: 1, total: 300m, amountPaid: 0m,
status: InvoiceStatus.Sent, invoiceDate: new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc));
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var timeline = view.ViewData["Timeline"] as List<CustomerTimelineEventDto>;
Assert.NotNull(timeline);
Assert.Equal(3, timeline.Count);
Assert.True(timeline[0].Date > timeline[1].Date, "First event should be the newest");
Assert.True(timeline[1].Date > timeline[2].Date, "Events should be descending");
}
// ── Details — tenant isolation ────────────────────────────────────────
[Fact]
public async Task Details_DoesNotIncludeJobsFromOtherCompanies()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var status = SeedJobStatus(context, id: 1, isTerminal: false);
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: status.Id, finalPrice: 500m); // this company
SeedJob(context, id: 2, customerId: 1, companyId: 2, statusId: status.Id, finalPrice: 999m); // other company
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(1, stats.TotalJobs);
Assert.Equal(500m, stats.AverageJobValue);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static CustomersController CreateController(ApplicationDbContext context, int companyId)
{
var uow = new UnitOfWork(context);
// Tests cover CrmStats/Timeline logic, not DTO mapping — stub mapper to return a valid model
var mapperMock = new Mock<IMapper>();
mapperMock
.Setup(m => m.Map<CustomerDto>(It.IsAny<Customer>()))
.Returns((Customer c) => new CustomerDto { Id = c.Id, CompanyName = c.CompanyName, IsActive = c.IsActive });
var mapper = mapperMock.Object;
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(t => t.GetCurrentCompanyId()).Returns(companyId);
var controller = new CustomersController(
uow,
mapper,
Mock.Of<ILogger<CustomersController>>(),
Mock.Of<INotificationService>(),
Mock.Of<ISubscriptionService>(),
tenantContext.Object,
CreateUserManagerMock().Object,
Mock.Of<IFinancialReportService>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
return controller;
}
private static void SeedCustomer(ApplicationDbContext context, int id, int companyId)
{
context.Customers.Add(new Customer
{
Id = id,
CompanyId = companyId,
CompanyName = $"Test Customer {id}",
IsActive = true,
CreatedAt = DateTime.UtcNow
});
}
private static JobStatusLookup SeedJobStatus(ApplicationDbContext context, int id, bool isTerminal)
{
var status = new JobStatusLookup
{
Id = id,
StatusCode = isTerminal ? "COMPLETED" : "IN_PROGRESS",
DisplayName = isTerminal ? "Completed" : "In Progress",
DisplayOrder = id,
IsTerminalStatus = isTerminal
};
context.JobStatusLookups.Add(status);
return status;
}
private static void SeedJob(
ApplicationDbContext context, int id, int customerId, int companyId, int statusId, decimal finalPrice)
{
context.Jobs.Add(new Job
{
Id = id,
CompanyId = companyId,
CustomerId = customerId,
JobStatusId = statusId,
JobNumber = $"JOB-0001-{id:D4}",
Description = $"Job {id}",
FinalPrice = finalPrice,
CreatedAt = DateTime.UtcNow
});
}
private static Job MakeJob(int id, int customerId, int companyId, int statusId, DateTime date) =>
new Job
{
Id = id,
CompanyId = companyId,
CustomerId = customerId,
JobStatusId = statusId,
JobNumber = $"JOB-0001-{id:D4}",
Description = $"Job {id}",
FinalPrice = 100m,
CreatedAt = date
};
private static void SeedInvoice(
ApplicationDbContext context, int id, int customerId, int companyId,
decimal total, decimal amountPaid, InvoiceStatus status,
DateTime? invoiceDate = null)
{
context.Invoices.Add(new Invoice
{
Id = id,
CompanyId = companyId,
CustomerId = customerId,
InvoiceNumber = $"INV-0001-{id:D4}",
InvoiceDate = invoiceDate ?? DateTime.UtcNow,
Total = total,
AmountPaid = amountPaid,
Status = status
});
}
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
// SuperAdmin principal: bypasses the CompanyId global query filter so all
// seeded rows are visible, matching the same approach in DepositsControllerTests.
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
var principal = new ClaimsPrincipal(identity);
byte[]? noBytes = null;
var sessionMock = new Mock<ISession>();
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.User).Returns(principal);
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
return new ApplicationDbContext(options, accessor.Object, null!);
}
}