Files
spouliot edd7389d7d Refactor: extract shared helpers, fix field drift, add assembly services
- IJobItemAssemblyService / IQuotePricingAssemblyService: centralize job item
  and quote pricing construction that was duplicated across create, rework copy,
  and quote-to-job conversion paths
- BlobFileHelper: single ValidateUpload/GetContentType/SanitizeFileName used by
  6 blob services (JobPhoto, QuotePhoto, ProfilePhoto, CompanyLogo, Equipment,
  Catalog) and BillsController + ExpensesController, removing 8 private copies
- PagedResult<T>.From(): static factory eliminates 6-line boilerplate in 11
  controllers (Appointments, Customers, Equipment, Inventory, Invoices, Jobs,
  Maintenance, CompanyUsers, PlatformUsers, Quotes, Vendors)
- AccountingDropdownHelper: single LoadAsync() call replaces duplicate
  vendor/account/job queries in BillsController and ExpensesController
- JobTemplateItem: add IsSalesItem + Sku fields with migration; propagate
  through JobTemplatesController snapshot copy and GetTemplatesJson projection,
  and JobsController template-application path
- Test assertions updated for standardized BlobFileHelper error messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:12:33 -04:00

240 lines
9.2 KiB
C#

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
namespace PowderCoating.UnitTests;
public class QuotePhotoServiceTests
{
[Fact]
public async Task SaveTempPhotoAsync_ReturnsError_WhenFileMissing()
{
var service = CreateService();
var result = await service.SaveTempPhotoAsync(null!, companyId: 1);
Assert.False(result.Success);
Assert.Equal("No file provided.", result.ErrorMessage);
}
[Fact]
public async Task SaveTempPhotoAsync_ReturnsError_WhenFileTooLarge()
{
var service = CreateService();
var file = CreateFormFile("huge.jpg", 10 * 1024 * 1024 + 1);
var result = await service.SaveTempPhotoAsync(file, companyId: 1);
Assert.False(result.Success);
Assert.Equal("File exceeds the 10 MB limit.", result.ErrorMessage);
}
[Fact]
public async Task SaveTempPhotoAsync_ReturnsError_WhenExtensionNotAllowed()
{
var service = CreateService();
var file = CreateFormFile("photo.bmp");
var result = await service.SaveTempPhotoAsync(file, companyId: 1);
Assert.False(result.Success);
Assert.Equal("File type not allowed. Allowed: .jpg, .jpeg, .png, .gif, .webp.", result.ErrorMessage);
}
[Fact]
public async Task SaveTempPhotoAsync_ReturnsBlobError_WhenUploadFails()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/png"))
.ReturnsAsync((false, "blob upload failed"));
var service = CreateService(blobService: blobService);
var file = CreateFormFile("photo.png");
var result = await service.SaveTempPhotoAsync(file, companyId: 1);
Assert.False(result.Success);
Assert.Equal("blob upload failed", result.ErrorMessage);
}
[Fact]
public async Task SaveTempPhotoAsync_UploadsToTempPath_WhenValid()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/jpeg"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService: blobService);
var file = CreateFormFile("photo.jpg");
var result = await service.SaveTempPhotoAsync(file, companyId: 5);
Assert.True(result.Success);
Assert.False(string.IsNullOrWhiteSpace(result.TempId));
Assert.StartsWith($"temp/{result.TempId}/", result.FilePath);
Assert.EndsWith(".jpg", result.FilePath);
}
[Fact]
public async Task PromoteTempPhotoAsync_ReturnsError_WhenTempPhotoMissing()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(Array.Empty<string>());
var service = CreateService(blobService: blobService);
var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3);
Assert.False(result.Success);
Assert.Equal("Temp photo not found.", result.ErrorMessage);
}
[Fact]
public async Task PromoteTempPhotoAsync_ReturnsError_WhenTempDownloadFails()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/original.png" });
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/original.png"))
.ReturnsAsync((false, Array.Empty<byte>(), string.Empty, "download failed"));
var service = CreateService(blobService: blobService);
var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3);
Assert.False(result.Success);
Assert.Equal("Failed to read temp photo.", result.ErrorMessage);
}
[Fact]
public async Task PromoteTempPhotoAsync_ReturnsError_WhenPermanentUploadFails()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/original.webp" });
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/original.webp"))
.ReturnsAsync((true, new byte[] { 1, 2, 3 }, "image/webp", string.Empty));
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/webp"))
.ReturnsAsync((false, "upload failed"));
var service = CreateService(blobService: blobService);
var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3);
Assert.False(result.Success);
Assert.Equal("Failed to save permanent photo.", result.ErrorMessage);
}
[Fact]
public async Task PromoteTempPhotoAsync_PromotesAndDeletesTempBlob_WhenSuccessful()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/original.png" });
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/original.png"))
.ReturnsAsync((true, new byte[] { 1, 2, 3 }, "image/png", string.Empty));
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/png"))
.ReturnsAsync((true, string.Empty));
blobService
.Setup(x => x.DeleteAsync("quoteimages", "temp/temp123/original.png"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService: blobService);
var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3);
Assert.True(result.Success);
Assert.StartsWith("3/quote-photos/10/", result.FilePath);
Assert.EndsWith(".png", result.FilePath);
blobService.Verify(x => x.DeleteAsync("quoteimages", "temp/temp123/original.png"), Times.Once);
}
[Fact]
public async Task ReadTempPhotosAsync_ReturnsOnlySuccessfulDownloads()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/one.jpg", "temp/temp123/two.jpg" });
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/one.jpg"))
.ReturnsAsync((true, new byte[] { 1 }, "image/jpeg", string.Empty));
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/two.jpg"))
.ReturnsAsync((false, Array.Empty<byte>(), string.Empty, "failed"));
var service = CreateService(blobService: blobService);
var result = await service.ReadTempPhotosAsync("temp123");
Assert.Single(result);
Assert.Equal("one.jpg", result[0].FileName);
Assert.Equal("image/jpeg", result[0].ContentType);
}
[Fact]
public async Task CleanupTempAsync_ContinuesDeleting_WhenOneDeleteThrows()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/one.jpg", "temp/temp123/two.jpg" });
blobService
.Setup(x => x.DeleteAsync("quoteimages", "temp/temp123/one.jpg"))
.ThrowsAsync(new InvalidOperationException("boom"));
blobService
.Setup(x => x.DeleteAsync("quoteimages", "temp/temp123/two.jpg"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService: blobService);
await service.CleanupTempAsync("temp123");
blobService.Verify(x => x.DeleteAsync("quoteimages", "temp/temp123/one.jpg"), Times.Once);
blobService.Verify(x => x.DeleteAsync("quoteimages", "temp/temp123/two.jpg"), Times.Once);
}
private static QuotePhotoService CreateService(Mock<IAzureBlobStorageService>? blobService = null)
{
var settings = Options.Create(new StorageSettings
{
Containers = new StorageContainers
{
QuoteImages = "quoteimages"
}
});
return new QuotePhotoService(
(blobService ?? new Mock<IAzureBlobStorageService>()).Object,
settings,
Mock.Of<ILogger<QuotePhotoService>>());
}
private static IFormFile CreateFormFile(string fileName, long? lengthOverride = null)
{
var dataLength = lengthOverride.HasValue
? (int)Math.Min(lengthOverride.Value, 1024)
: 16;
var bytes = Enumerable.Repeat((byte)65, dataLength).ToArray();
var stream = new MemoryStream(bytes);
return new FormFile(stream, 0, lengthOverride ?? bytes.Length, "file", fileName);
}
}