shoc-backend/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs
Alexandre Brandizzi 8515676f4d feat(vendors): add admin-assigned vendor company Area
Seed an organization-wide Area catalogue (East, Central, West, California)
with stable ids, add a nullable AreaId to VendorCompany, allow only Admins
to change it through the roster endpoints, expose areas facet metadata and
an areas[n] company-directory filter with the __unassigned__ sentinel.
2026-09-16 11:34:45 -03:00

879 lines
38 KiB
C#

using Data.SeaHavenIndustries;
using FluentAssertions;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using System.Security.Claims;
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 readonly ClaimsPrincipal Dispatcher = Principal("User");
private static readonly ClaimsPrincipal Admin = Principal("Admin");
private static ClaimsPrincipal Principal(string role) => new(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.Role, role) },
"Test"));
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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, CancellationToken.None);
captured.Should().NotBeNull();
captured!.Technicians.Should().BeEmpty();
captured.Name.Should().Be("Acme");
}
[Fact]
public async Task Create_AcceptsNotesAt500Characters()
{
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 notes = new string('n', 500);
await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Notes = notes
}, "42", Dispatcher, CancellationToken.None);
captured!.Notes.Should().Be(notes);
}
[Fact]
public async Task Create_RejectsNotesAt501Characters()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Notes = new string('n', 501)
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
error.PropertyName == nameof(CreateVendorRosterDTO.Notes)
&& error.ErrorMessage == "Notes cannot exceed 500 characters.");
data.Verify(
x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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_AcceptsNotesAt500Characters()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel());
var notes = new string('n', 500);
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
Notes = notes
}, "42", Dispatcher, CancellationToken.None);
captured!.Notes.Should().Be(notes);
}
[Fact]
public async Task Reconcile_RejectsNotesAt501Characters()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
Notes = new string('n', 501)
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
error.PropertyName == nameof(ReconcileVendorRosterDTO.Notes)
&& error.ErrorMessage == "Notes cannot exceed 500 characters.");
data.Verify(
x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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" }), "", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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_AcceptsCompanyNotesAt500Characters()
{
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());
var notes = new string('n', 500);
await NewService(data).AddTechniciansAsync(7, new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes }
}, "42", Dispatcher, CancellationToken.None);
captured!.CompanyFields!.Notes.Should().Be(notes);
}
[Fact]
public async Task AddTechnicians_RejectsCompanyNotesAt501Characters()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
error.PropertyName == "CompanyFields.Notes"
&& error.ErrorMessage == "Notes cannot exceed 500 characters.");
data.Verify(
x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[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", Dispatcher, CancellationToken.None);
// SH-250: every field is blank, so there is no company change to apply. Forwarding a
// non-null write model made the data layer bump RowVersion and rewrite every
// technician's LastModificationTime for a request that changes no company data.
captured!.CompanyFields.Should().BeNull();
}
[Fact]
public async Task AddTechnicians_AllBlankCompanyFieldsAndNoTechnicians_FailsValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
AddTechnicians = new List<RosterTechnicianInputDTO>(),
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, 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_RealCompanyValueStillForwarded()
{
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>(),
CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" }
};
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
captured!.CompanyFields.Should().NotBeNull();
captured.CompanyFields!.City.Should().Be("Norfolk");
}
[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", Dispatcher, cts.Token);
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), cts.Token), Times.Once);
}
private static VendorCompanyRosterReadModel ReadModelWithArea(int? areaId)
{
var model = SampleReadModel(7, 1);
model.AreaId = areaId;
return model;
}
[Fact]
public async Task Create_NonAdminAssigningArea_IsForbiddenAndNotPersisted()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 };
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny<CancellationToken>())).ReturnsAsync(false);
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 99 };
var act = () => NewService(data).CreateRosterAsync(dto, "42", Admin, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId));
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AdminAssigningActiveArea_PersistsAreaId()
{
using var cts = new CancellationTokenSource();
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true);
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(ReadModelWithArea(2));
var result = await NewService(data).CreateRosterAsync(
new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 2 },
"42",
Admin,
cts.Token);
captured!.AreaId.Should().Be(2);
result.AreaId.Should().Be(2);
data.Verify(x => x.IsActiveAreaAsync(2, cts.Token), Times.Once);
}
[Fact]
public async Task Reconcile_NonAdminChangingOrClearingArea_IsForbiddenAndNotPersisted()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
foreach (int? requested in new int?[] { 3, null })
{
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
AreaId = requested
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
}
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
var captured = new List<VendorCompanyRosterWriteModel>();
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured.Add(model))
.ReturnsAsync(ReadModelWithArea(1));
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme Renamed",
Email = "acme@example.com",
AreaId = 1
}, "42", Dispatcher, CancellationToken.None);
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme Renamed",
Email = "acme@example.com"
}, "42", Dispatcher, CancellationToken.None);
captured.Should().HaveCount(2);
captured[0].AreaIdProvided.Should().BeTrue();
captured[0].AreaId.Should().Be(1);
captured[1].AreaIdProvided.Should().BeFalse();
}
[Fact]
public async Task Reconcile_AdminClearsAreaToUnassigned()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(ReadModelWithArea(null));
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
AreaId = null
}, "42", Admin, CancellationToken.None);
captured!.AreaIdProvided.Should().BeTrue();
captured.AreaId.Should().BeNull();
data.Verify(x => x.IsActiveAreaAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_NonAdminAreaChange_IsForbidden()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(null));
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
CompanyFields = new VendorRosterCompanyFieldsDTO { AreaId = 2 }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull()
{
var omitted = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
var cleared = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
var assigned = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":3}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
omitted.AreaIdProvided.Should().BeFalse();
cleared.AreaIdProvided.Should().BeTrue();
cleared.AreaId.Should().BeNull();
assigned.AreaIdProvided.Should().BeTrue();
assigned.AreaId.Should().Be(3);
}
}