mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
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.
879 lines
38 KiB
C#
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);
|
|
}
|
|
}
|