shoc-backend/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs

529 lines
22 KiB
C#
Raw Normal View History

using Data.SeaHavenIndustries;
using FluentAssertions;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorCompanyRosterServiceTests
{
private static VendorCompanyRosterService NewService(Mock<IVendorCompanyRosterDataService> data) => new(data.Object);
private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new()
{
CompanyId = companyId,
Name = "Acme",
CompanyPhone = "(312) 555-0199",
Email = "acme@example.com",
RowVersion = new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 },
Technicians = technicianIds.Select(id => new VendorRosterTechnicianReadModel { Id = id, ContactName = $"Tech {id}", IsActive = true }).ToList()
};
[Fact]
public async Task Get_WithoutAuthenticatedUser_Throws()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var act = () => NewService(data).GetRosterAsync(1, null, "", CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
data.Verify(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Get_WithoutVendorOrCompanyId_ThrowsValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var act = () => NewService(data).GetRosterAsync(null, null, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
}
[Fact]
public async Task Create_WithoutCompanyPhoneOrEmail_ThrowsContactGroupValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme" };
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_InvalidCompanyPhone_DoesNotSatisfyContactGroup()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" };
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone));
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_RejectsExistingTechnicianIds()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 7, ContactName = "Riley" } }
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AcceptsZeroTechniciansWhenContactGroupPresent()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel());
await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>()
}, "42", CancellationToken.None);
captured.Should().NotBeNull();
captured!.Technicians.Should().BeEmpty();
captured.Name.Should().Be("Acme");
}
[Fact]
public async Task Reconcile_RejectsDuplicateTechnicianIds()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>
{
new() { Id = 5, ContactName = "Dup" },
new() { Id = 5, ContactName = "Dup Again" }
}
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_RejectsMismatchedTechnicianIds()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleReadModel(7, 1, 2));
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>
{
new() { Id = 1, ContactName = "Ok" },
new() { Id = 999, ContactName = "Foreign" }
}
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_RejectsNonHttpsMapUrl()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
GoogleMapsUrl = "http://maps.google.com/acme"
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl));
}
[Fact]
public async Task Create_NormalizesTechnicianPhoneBeforeForwarding()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel());
await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>
{
new() { ContactName = "Riley", Phone = "+1 312 555 0100" }
}
}, "42", CancellationToken.None);
captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100");
}
[Fact]
public async Task Create_RejectsInvalidTechnicianPhoneFormat()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>
{
new() { ContactName = "Riley", Phone = "555-1234" }
}
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
}
[Fact]
public async Task Reconcile_ParsesRowVersionAndForwardsCompanyId()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleReadModel(7, 1));
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel(7, 1));
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme Co",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
}, "42", CancellationToken.None);
captured.Should().NotBeNull();
captured!.CompanyId.Should().Be(7);
captured.RowVersion.Should().Equal(Convert.FromBase64String("AAAAAAAAD8I="));
captured.Name.Should().Be("Acme Co");
}
[Fact]
public async Task Reconcile_RejectsMalformedRowVersion()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleReadModel(7));
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "not-valid-base64!!",
Name = "Acme",
Email = "acme@example.com"
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_RejectsMissingRowVersion()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "",
Name = "Acme",
Email = "acme@example.com"
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_NullTechnicians_IsTreatedAsEmptyRoster()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel());
var dto = new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = null!
};
await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
captured!.Technicians.Should().BeEmpty();
}
[Theory]
[InlineData("Riley", "not-an-email", "Phone")]
[InlineData("Riley", "tech@example.com", "CarrierPigeon")]
public async Task Create_RejectsInvalidTechnicianFields(string name, string email, string preferredContact)
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>
{
new() { ContactName = name, Email = email, PreferredContact = preferredContact }
}
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AcceptsTechnicianWithoutNameOrPhone()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel());
await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>
{
new() { ContactName = "", Phone = "", PreferredContact = "Phone" }
}
}, "42", CancellationToken.None);
captured!.Technicians.Should().ContainSingle();
captured.Technicians.Single().ContactName.Should().BeEmpty();
captured.Technicians.Single().Phone.Should().BeNullOrEmpty();
}
[Fact]
public async Task Reconcile_UnknownCompanyWithTechnicianIds_ThrowsValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 404, It.IsAny<CancellationToken>()))
.ReturnsAsync((VendorCompanyRosterReadModel?)null);
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
};
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
}
[Fact]
public async Task Reconcile_ForwardsCancellation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleReadModel(7, 1));
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleReadModel(7, 1));
using var cts = new CancellationTokenSource();
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
}, "42", cts.Token);
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
}
private static AddTechniciansVendorRosterDTO AddDto(params RosterTechnicianInputDTO[] technicians) => new()
{
RowVersion = "AAAAAAAAD8I=",
AddTechnicians = technicians.ToList()
};
[Fact]
public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_RejectsTechnicianIds()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" });
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_RejectsEmptyPayload()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" };
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_RejectsMissingRowVersion()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "",
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_RejectsInvalidTechnicianPhoneFormat()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" });
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_NormalizesAndMapsBeforeForwarding()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterAddWriteModel? captured = null;
data.Setup(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterAddWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel(7, 1));
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
AddTechnicians = new List<RosterTechnicianInputDTO>
{
new() { ContactName = "Riley", Phone = "+1 312 555 0100", TradeSpecialties = "HVAC" }
},
CompanyFields = new VendorRosterCompanyFieldsDTO
{
Name = " Renamed Co ",
Notes = "fresh notes"
}
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
captured.Should().NotBeNull();
captured!.CompanyId.Should().Be(7);
captured.RowVersion.Should().Equal(Convert.FromBase64String("AAAAAAAAD8I="));
captured.ActorUserId.Should().Be("42");
captured.AddTechnicians.Single().Phone.Should().Be("(312) 555-0100");
captured.AddTechnicians.Single().TradeSpecialties.Should().Be("HVAC");
captured.AddTechnicians.Single().IsActive.Should().BeTrue();
captured.CompanyFields.Should().NotBeNull();
captured.CompanyFields!.Name.Should().Be("Renamed Co");
captured.CompanyFields.Notes.Should().Be("fresh notes");
captured.CompanyFields.CompanyPhone.Should().BeNull();
captured.CompanyFields.Email.Should().BeNull();
}
[Fact]
public async Task AddTechnicians_BlankCompanyFieldsMeanUnchangedAndKeepContactGroup()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterAddWriteModel? captured = null;
data.Setup(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterAddWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel(7, 1));
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } },
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
captured!.CompanyFields.Should().NotBeNull();
captured.CompanyFields!.CompanyPhone.Should().BeNull();
captured.CompanyFields.Email.Should().BeNull();
}
[Fact]
public async Task AddTechnicians_ForwardsCancellation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleReadModel(7, 1));
using var cts = new CancellationTokenSource();
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token);
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), cts.Token), Times.Once);
}
}