711cd01cd3
- 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>
379 lines
16 KiB
C#
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!);
|
|
}
|
|
}
|