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(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(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(result); var timeline = view.ViewData["Timeline"] as List; 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(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(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(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(result); var timeline = view.ViewData["Timeline"] as List; 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(result); var timeline = view.ViewData["Timeline"] as List; 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(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(); mapperMock .Setup(m => m.Map(It.IsAny())) .Returns((Customer c) => new CustomerDto { Id = c.Id, CompanyName = c.CompanyName, IsActive = c.IsActive }); var mapper = mapperMock.Object; var tenantContext = new Mock(); tenantContext.Setup(t => t.GetCurrentCompanyId()).Returns(companyId); var controller = new CustomersController( uow, mapper, Mock.Of>(), Mock.Of(), Mock.Of(), tenantContext.Object, CreateUserManagerMock().Object, Mock.Of()); 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> CreateUserManagerMock() { var store = new Mock>(); return new Mock>( store.Object, null!, null!, null!, null!, null!, null!, null!, null!); } private static ApplicationDbContext CreateContext() { var options = new DbContextOptionsBuilder() .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(); sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); var httpContextMock = new Mock(); httpContextMock.SetupGet(c => c.User).Returns(principal); httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); var accessor = new Mock(); accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); return new ApplicationDbContext(options, accessor.Object, null!); } }