mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +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.
877 lines
34 KiB
C#
877 lines
34 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using FluentAssertions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using SeaHaven.DataServices.Implementation;
|
|
using SeaHaven.DataServices.Models;
|
|
using Xunit;
|
|
|
|
namespace Api.SeaHavenIndustries.Tests;
|
|
|
|
public class VendorCompanyRosterDataServiceTests
|
|
{
|
|
private static ApplicationDbContext NewContext(string dbName)
|
|
{
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(dbName)
|
|
.Options;
|
|
return new ApplicationDbContext(options);
|
|
}
|
|
|
|
private static Task<(int companyId, Vendor vendor)> SeedCompanyWithTechnicianAsync(
|
|
ApplicationDbContext context, string companyName, params string[] technicianNames)
|
|
=> SeedCompanyWithTechnicianAsync(context, companyName, rowVersion: null, technicianNames);
|
|
|
|
private static async Task<(int companyId, Vendor vendor)> SeedCompanyWithTechnicianAsync(
|
|
ApplicationDbContext context, string companyName, byte[]? rowVersion, params string[] technicianNames)
|
|
{
|
|
var company = new VendorCompany { Name = companyName, NormalizedName = companyName.ToLowerInvariant(), RowVersion = rowVersion };
|
|
context.VendorCompanies.Add(company);
|
|
|
|
foreach (var name in technicianNames)
|
|
{
|
|
context.Vendors.Add(new Vendor
|
|
{
|
|
CompanyName = companyName,
|
|
ContactName = name,
|
|
IsActive = true,
|
|
IsDeleted = false,
|
|
Company = company
|
|
});
|
|
}
|
|
|
|
await context.SaveChangesAsync();
|
|
return (company.Id, context.Vendors.First(v => v.CompanyId == company.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRoster_PersistsCompanyAndTechniciansAtomically()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
using var context = NewContext(dbName);
|
|
var service = new VendorCompanyRosterDataService(context);
|
|
|
|
var roster = await service.CreateRosterAsync(new VendorCompanyRosterWriteModel
|
|
{
|
|
Name = "Atomic Co",
|
|
Email = "atomic@example.com",
|
|
Notes = "shared notes",
|
|
ActorUserId = "42",
|
|
Technicians = new List<RosterTechnicianWriteModel>
|
|
{
|
|
new() { ContactName = "T1" },
|
|
new() { ContactName = "T2" }
|
|
}
|
|
}, CancellationToken.None);
|
|
|
|
roster.CompanyId.Should().BeGreaterThan(0);
|
|
roster.Technicians.Should().HaveCount(2);
|
|
roster.Notes.Should().Be("shared notes");
|
|
|
|
var company = context.VendorCompanies.Single(c => c.Id == roster.CompanyId);
|
|
company.Name.Should().Be("Atomic Co");
|
|
context.Vendors.Count(v => v.CompanyId == company.Id).Should().Be(2);
|
|
context.Vendors.Where(v => v.CompanyId == company.Id).Should().OnlyContain(v => v.CompanyName == "Atomic Co");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRoster_DuplicateNormalizedName_ThrowsWithoutPersisting()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
using var context = NewContext(dbName);
|
|
await SeedCompanyWithTechnicianAsync(context, "Eletrizante", "Existing");
|
|
var service = new VendorCompanyRosterDataService(context);
|
|
|
|
var call = () => service.CreateRosterAsync(new VendorCompanyRosterWriteModel
|
|
{
|
|
Name = " ELETRIZANTE ",
|
|
Email = "duplicate@example.com",
|
|
ActorUserId = "42"
|
|
}, CancellationToken.None);
|
|
|
|
await call.Should().ThrowAsync<VendorRosterDuplicateNameException>();
|
|
context.VendorCompanies.Should().ContainSingle();
|
|
context.Vendors.Should().ContainSingle();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetRoster_ByVendorId_ResolvesCompanyAndAllTechnicians()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId, vendorId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Resolve Co", "A", "B");
|
|
companyId = cid;
|
|
vendorId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "A").Id;
|
|
}
|
|
|
|
using var context = NewContext(dbName);
|
|
var service = new VendorCompanyRosterDataService(context);
|
|
|
|
var roster = await service.GetRosterAsync(vendorId, null, CancellationToken.None);
|
|
|
|
roster.Should().NotBeNull();
|
|
roster!.CompanyId.Should().Be(companyId);
|
|
roster.Technicians.Select(t => t.ContactName).Should().BeEquivalentTo(new[] { "A", "B" });
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetRoster_WithMismatchedVendorAndCompanyIds_ReturnsNull()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int firstCompanyId, secondVendorId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var (firstId, _) = await SeedCompanyWithTechnicianAsync(seed, "First Co", "A");
|
|
var (_, secondVendor) = await SeedCompanyWithTechnicianAsync(seed, "Second Co", "B");
|
|
firstCompanyId = firstId;
|
|
secondVendorId = secondVendor.Id;
|
|
}
|
|
|
|
using var context = NewContext(dbName);
|
|
var service = new VendorCompanyRosterDataService(context);
|
|
|
|
var roster = await service.GetRosterAsync(secondVendorId, firstCompanyId, CancellationToken.None);
|
|
|
|
roster.Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveRoster_RenamesCompanyAndTechnicianAndUpdatesNotes()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId, techId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var (cid, vendor) = await SeedCompanyWithTechnicianAsync(seed, "Old Co", "Old Name");
|
|
companyId = cid;
|
|
techId = vendor.Id;
|
|
}
|
|
|
|
using var context = NewContext(dbName);
|
|
var service = new VendorCompanyRosterDataService(context);
|
|
|
|
var roster = await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
Name = "New Co",
|
|
Email = "new@example.com",
|
|
Notes = "updated notes",
|
|
ActorUserId = "42",
|
|
Technicians = new List<RosterTechnicianWriteModel>
|
|
{
|
|
new() { Id = techId, ContactName = "New Name", IsActive = true }
|
|
}
|
|
}, CancellationToken.None);
|
|
|
|
roster.Name.Should().Be("New Co");
|
|
roster.Notes.Should().Be("updated notes");
|
|
var tech = context.Vendors.Single(v => v.Id == techId);
|
|
tech.ContactName.Should().Be("New Name");
|
|
tech.CompanyName.Should().Be("New Co");
|
|
context.VendorCompanies.Single(c => c.Id == companyId).Notes.Should().Be("updated notes");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveRoster_SoftDeletesOmittedTechnicianWithoutOpenWorkOrders()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId, keepId, dropId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Drop Co", "Keep", "Drop");
|
|
companyId = cid;
|
|
keepId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "Keep").Id;
|
|
dropId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "Drop").Id;
|
|
}
|
|
|
|
using (var act = NewContext(dbName))
|
|
{
|
|
var service = new VendorCompanyRosterDataService(act);
|
|
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
Name = "Drop Co",
|
|
Email = "drop@example.com",
|
|
ActorUserId = "42",
|
|
Technicians = new List<RosterTechnicianWriteModel>
|
|
{
|
|
new() { Id = keepId, ContactName = "Keep", IsActive = true }
|
|
}
|
|
}, CancellationToken.None);
|
|
}
|
|
|
|
using var verify = NewContext(dbName);
|
|
var kept = verify.Vendors.Single(v => v.Id == keepId);
|
|
kept.IsDeleted.Should().NotBeTrue();
|
|
var dropped = verify.Vendors.Single(v => v.Id == dropId);
|
|
dropped.IsDeleted.Should().BeTrue();
|
|
dropped.IsActive.Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveRoster_RemovingAllTechniciansLeavesZeroVendorsAndCompanyRemains()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Empty Co", "T1", "T2");
|
|
companyId = cid;
|
|
}
|
|
|
|
using (var act = NewContext(dbName))
|
|
{
|
|
var service = new VendorCompanyRosterDataService(act);
|
|
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
Name = "Empty Co",
|
|
Email = "empty@example.com",
|
|
ActorUserId = "42",
|
|
Technicians = new List<RosterTechnicianWriteModel>()
|
|
}, CancellationToken.None);
|
|
}
|
|
|
|
using var verify = NewContext(dbName);
|
|
verify.Vendors.Count(v => v.CompanyId == companyId && (v.IsDeleted == null || v.IsDeleted == false)).Should().Be(0);
|
|
verify.VendorCompanies.Any(c => c.Id == companyId).Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveRoster_RemovalBlockedByOpenWorkOrdersFailsWholeSave()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId, vendorId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var (cid, vendor) = await SeedCompanyWithTechnicianAsync(seed, "Blocked Co", "Busy");
|
|
companyId = cid;
|
|
vendorId = vendor.Id;
|
|
seed.workOrders.Add(new WorkOrder { Id = 800, LifecycleStatus = LifecycleStatus.Scheduled, Status = "Scheduled" });
|
|
seed.Dispatches.Add(new Dispatch { VendorId = vendorId, WorkOrderId = 800, Vendor = vendor });
|
|
await seed.SaveChangesAsync();
|
|
}
|
|
|
|
using (var act = NewContext(dbName))
|
|
{
|
|
var service = new VendorCompanyRosterDataService(act);
|
|
var actCall = () => service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
RowVersion = new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 },
|
|
Name = "Blocked Co",
|
|
Email = "blocked@example.com",
|
|
ActorUserId = "42",
|
|
Technicians = new List<RosterTechnicianWriteModel>()
|
|
}, CancellationToken.None);
|
|
|
|
await actCall.Should().ThrowAsync<VendorRosterConflictException>();
|
|
}
|
|
|
|
using var verify = NewContext(dbName);
|
|
var tech = verify.Vendors.Single(v => v.Id == vendorId);
|
|
tech.IsDeleted.Should().NotBeTrue();
|
|
tech.IsActive.Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetRoster_ForwardsCancellation()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
using var context = NewContext(dbName);
|
|
var service = new VendorCompanyRosterDataService(context);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
var act = () => service.GetRosterAsync(1, null, cts.Token);
|
|
|
|
await act.Should().ThrowAsync<OperationCanceledException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveRoster_PropagatesCompanyLocationAndPhoneToRetainedTechnician()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId, techId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var company = new VendorCompany
|
|
{
|
|
Name = "Loc Co",
|
|
NormalizedName = "loc co",
|
|
CompanyPhone = "(111) 111-0001",
|
|
Address = "Old Addr",
|
|
City = "OldCity",
|
|
State = "OS",
|
|
Zip = "00001",
|
|
GoogleMapsUrl = "https://old.example/map"
|
|
};
|
|
seed.VendorCompanies.Add(company);
|
|
seed.Vendors.Add(new Vendor
|
|
{
|
|
CompanyName = "Loc Co",
|
|
ContactName = "Keeper",
|
|
IsActive = true,
|
|
IsDeleted = false,
|
|
Company = company,
|
|
CompanyPhone = "(111) 111-0001",
|
|
Address = "Old Addr",
|
|
City = "OldCity",
|
|
State = "OS",
|
|
Zip = "00001",
|
|
GoogleMapsUrl = "https://old.example/map"
|
|
});
|
|
await seed.SaveChangesAsync();
|
|
companyId = company.Id;
|
|
techId = seed.Vendors.First(v => v.CompanyId == companyId).Id;
|
|
}
|
|
|
|
using (var act = NewContext(dbName))
|
|
{
|
|
var service = new VendorCompanyRosterDataService(act);
|
|
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
Name = "Loc Co",
|
|
CompanyPhone = "(222) 222-0002",
|
|
Address = "New Addr",
|
|
City = "NewCity",
|
|
State = "NS",
|
|
Zip = "00002",
|
|
GoogleMapsUrl = "https://new.example/map",
|
|
ActorUserId = "42",
|
|
Technicians = new List<RosterTechnicianWriteModel>
|
|
{
|
|
new() { Id = techId, ContactName = "Keeper", IsActive = true }
|
|
}
|
|
}, CancellationToken.None);
|
|
}
|
|
|
|
using var verify = NewContext(dbName);
|
|
var tech = verify.Vendors.Single(v => v.Id == techId);
|
|
tech.CompanyPhone.Should().Be("(222) 222-0002");
|
|
tech.Address.Should().Be("New Addr");
|
|
tech.City.Should().Be("NewCity");
|
|
tech.State.Should().Be("NS");
|
|
tech.Zip.Should().Be("00002");
|
|
tech.GoogleMapsUrl.Should().Be("https://new.example/map");
|
|
|
|
var directory = new VendorDataService(verify);
|
|
var newCity = await directory.GetDirectoryPagedAsync(1, 10, search: "NewCity");
|
|
newCity.Items.Should().ContainSingle(row => row.VendorId == techId);
|
|
|
|
var oldCity = await directory.GetDirectoryPagedAsync(1, 10, search: "OldCity");
|
|
oldCity.Items.Should().NotContain(row => row.VendorId == techId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveRoster_ClearsCompanyOwnedFieldsOnRetainedTechnicianWhenCompanyClearsThem()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId, techId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var company = new VendorCompany
|
|
{
|
|
Name = "Clear Co",
|
|
NormalizedName = "clear co",
|
|
CompanyPhone = "(333) 333-0003",
|
|
Address = "Keep Addr",
|
|
City = "KeepCity",
|
|
State = "KS",
|
|
Zip = "00003",
|
|
GoogleMapsUrl = "https://keep.example/map"
|
|
};
|
|
seed.VendorCompanies.Add(company);
|
|
seed.Vendors.Add(new Vendor
|
|
{
|
|
CompanyName = "Clear Co",
|
|
ContactName = "Keeper",
|
|
IsActive = true,
|
|
IsDeleted = false,
|
|
Company = company,
|
|
CompanyPhone = "(333) 333-0003",
|
|
Address = "Keep Addr",
|
|
City = "KeepCity",
|
|
State = "KS",
|
|
Zip = "00003",
|
|
GoogleMapsUrl = "https://keep.example/map"
|
|
});
|
|
await seed.SaveChangesAsync();
|
|
companyId = company.Id;
|
|
techId = seed.Vendors.First(v => v.CompanyId == companyId).Id;
|
|
}
|
|
|
|
using (var act = NewContext(dbName))
|
|
{
|
|
var service = new VendorCompanyRosterDataService(act);
|
|
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
Name = "Clear Co",
|
|
CompanyPhone = null,
|
|
Address = null,
|
|
City = null,
|
|
State = null,
|
|
Zip = null,
|
|
GoogleMapsUrl = null,
|
|
ActorUserId = "42",
|
|
Technicians = new List<RosterTechnicianWriteModel>
|
|
{
|
|
new() { Id = techId, ContactName = "Keeper", IsActive = true }
|
|
}
|
|
}, CancellationToken.None);
|
|
}
|
|
|
|
using var verify = NewContext(dbName);
|
|
var tech = verify.Vendors.Single(v => v.Id == techId);
|
|
tech.CompanyPhone.Should().BeNull();
|
|
tech.Address.Should().BeNull();
|
|
tech.City.Should().BeNull();
|
|
tech.State.Should().BeNull();
|
|
tech.Zip.Should().BeNull();
|
|
tech.GoogleMapsUrl.Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveRoster_NewTechnicianReceivesCompanyPhoneAndLocation()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var company = new VendorCompany
|
|
{
|
|
Name = "Add Co",
|
|
NormalizedName = "add co"
|
|
};
|
|
seed.VendorCompanies.Add(company);
|
|
await seed.SaveChangesAsync();
|
|
companyId = company.Id;
|
|
}
|
|
|
|
int newTechId;
|
|
using (var act = NewContext(dbName))
|
|
{
|
|
var service = new VendorCompanyRosterDataService(act);
|
|
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
Name = "Add Co",
|
|
CompanyPhone = "(444) 444-0004",
|
|
Address = "Add Addr",
|
|
City = "AddCity",
|
|
State = "AS",
|
|
Zip = "00004",
|
|
GoogleMapsUrl = "https://add.example/map",
|
|
ActorUserId = "42",
|
|
Technicians = new List<RosterTechnicianWriteModel>
|
|
{
|
|
new() { ContactName = "Fresh", IsActive = true }
|
|
}
|
|
}, CancellationToken.None);
|
|
newTechId = act.Vendors.First(v => v.CompanyId == companyId && v.ContactName == "Fresh").Id;
|
|
}
|
|
|
|
using var verify = NewContext(dbName);
|
|
var tech = verify.Vendors.Single(v => v.Id == newTechId);
|
|
tech.CompanyPhone.Should().Be("(444) 444-0004");
|
|
tech.Address.Should().Be("Add Addr");
|
|
tech.City.Should().Be("AddCity");
|
|
tech.State.Should().Be("AS");
|
|
tech.Zip.Should().Be("00004");
|
|
tech.GoogleMapsUrl.Should().Be("https://add.example/map");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateRoster_NewTechnicianReceivesCompanyPhoneAndLocation()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
using var context = NewContext(dbName);
|
|
var service = new VendorCompanyRosterDataService(context);
|
|
|
|
var roster = await service.CreateRosterAsync(new VendorCompanyRosterWriteModel
|
|
{
|
|
Name = "Create Co",
|
|
CompanyPhone = "(555) 555-0005",
|
|
Address = "Create Addr",
|
|
City = "CreateCity",
|
|
State = "CS",
|
|
Zip = "00005",
|
|
GoogleMapsUrl = "https://create.example/map",
|
|
ActorUserId = "42",
|
|
Technicians = new List<RosterTechnicianWriteModel>
|
|
{
|
|
new() { ContactName = "Brand", IsActive = true }
|
|
}
|
|
}, CancellationToken.None);
|
|
|
|
var tech = context.Vendors.Single(v => v.CompanyId == roster.CompanyId);
|
|
tech.CompanyPhone.Should().Be("(555) 555-0005");
|
|
tech.Address.Should().Be("Create Addr");
|
|
tech.City.Should().Be("CreateCity");
|
|
tech.State.Should().Be("CS");
|
|
tech.Zip.Should().Be("00005");
|
|
tech.GoogleMapsUrl.Should().Be("https://create.example/map");
|
|
}
|
|
|
|
private static byte[] InitialRowVersion() => new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 };
|
|
|
|
[Fact]
|
|
public async Task AddTechnicians_RenameToTakenName_ThrowsDuplicateNameConflict()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Gateway Plumbing", InitialRowVersion(), "First");
|
|
companyId = cid;
|
|
await SeedCompanyWithTechnicianAsync(seed, "Harbor Electric", InitialRowVersion(), "Other");
|
|
}
|
|
|
|
using (var act = NewContext(dbName))
|
|
{
|
|
var service = new VendorCompanyRosterDataService(act);
|
|
var call = () => service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
RowVersion = InitialRowVersion(),
|
|
ActorUserId = "42",
|
|
CompanyFields = new VendorRosterCompanyFieldsWriteModel { Name = "Harbor Electric" },
|
|
AddTechnicians = new List<RosterTechnicianWriteModel>()
|
|
}, CancellationToken.None);
|
|
|
|
// SH-250: a colliding rename must surface as a stable client conflict, not as an
|
|
// unhandled DbUpdateException that the controller reports as a 500.
|
|
await call.Should().ThrowAsync<VendorRosterDuplicateNameException>();
|
|
}
|
|
|
|
using var verify = NewContext(dbName);
|
|
verify.VendorCompanies.Single(c => c.Id == companyId).Name.Should().Be("Gateway Plumbing");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTechnicians_RenameToFreeName_Succeeds()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Gateway Plumbing", InitialRowVersion(), "First");
|
|
companyId = cid;
|
|
}
|
|
|
|
using (var act = NewContext(dbName))
|
|
{
|
|
var service = new VendorCompanyRosterDataService(act);
|
|
await service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
RowVersion = InitialRowVersion(),
|
|
ActorUserId = "42",
|
|
CompanyFields = new VendorRosterCompanyFieldsWriteModel { Name = "Gateway Plumbing & Drain" },
|
|
AddTechnicians = new List<RosterTechnicianWriteModel>()
|
|
}, CancellationToken.None);
|
|
}
|
|
|
|
using var verify = NewContext(dbName);
|
|
var company = verify.VendorCompanies.Single(c => c.Id == companyId);
|
|
company.Name.Should().Be("Gateway Plumbing & Drain");
|
|
company.NormalizedName.Should().Be("gateway plumbing & drain");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTechnicians_LeavesAllPreExistingTechniciansIntact()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId, firstId, secondId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Add Co", InitialRowVersion(), "First", "Second");
|
|
companyId = cid;
|
|
firstId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "First").Id;
|
|
secondId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "Second").Id;
|
|
}
|
|
|
|
using (var act = NewContext(dbName))
|
|
{
|
|
var service = new VendorCompanyRosterDataService(act);
|
|
var roster = await service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
RowVersion = InitialRowVersion(),
|
|
ActorUserId = "42",
|
|
AddTechnicians = new List<RosterTechnicianWriteModel>
|
|
{
|
|
new() { ContactName = "Third", Phone = "(312) 555-0100", IsActive = true }
|
|
}
|
|
}, CancellationToken.None);
|
|
|
|
roster.Technicians.Select(t => t.ContactName).Should().BeEquivalentTo(new[] { "First", "Second", "Third" });
|
|
Convert.ToBase64String(roster.RowVersion!).Should().NotBe(Convert.ToBase64String(InitialRowVersion()));
|
|
}
|
|
|
|
using var verify = NewContext(dbName);
|
|
var first = verify.Vendors.Single(v => v.Id == firstId);
|
|
first.IsDeleted.Should().NotBeTrue();
|
|
first.IsActive.Should().BeTrue();
|
|
first.ContactName.Should().Be("First");
|
|
var second = verify.Vendors.Single(v => v.Id == secondId);
|
|
second.IsDeleted.Should().NotBeTrue();
|
|
second.IsActive.Should().BeTrue();
|
|
second.ContactName.Should().Be("Second");
|
|
var added = verify.Vendors.Single(v => v.ContactName == "Third");
|
|
added.CompanyId.Should().Be(companyId);
|
|
added.IsActive.Should().BeTrue();
|
|
added.Phone.Should().Be("(312) 555-0100");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTechnicians_SucceedsWhenExistingTechniciansHaveOpenWorkOrders()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId, busyId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var (cid, vendor) = await SeedCompanyWithTechnicianAsync(seed, "Busy Co", InitialRowVersion(), "Busy");
|
|
companyId = cid;
|
|
busyId = vendor.Id;
|
|
seed.workOrders.Add(new WorkOrder { Id = 900, LifecycleStatus = LifecycleStatus.Scheduled, Status = "Scheduled" });
|
|
seed.Dispatches.Add(new Dispatch { VendorId = busyId, WorkOrderId = 900, Vendor = vendor });
|
|
await seed.SaveChangesAsync();
|
|
}
|
|
|
|
using (var act = NewContext(dbName))
|
|
{
|
|
var service = new VendorCompanyRosterDataService(act);
|
|
var roster = await service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
RowVersion = InitialRowVersion(),
|
|
ActorUserId = "42",
|
|
AddTechnicians = new List<RosterTechnicianWriteModel>
|
|
{
|
|
new() { ContactName = "Newcomer", IsActive = true }
|
|
}
|
|
}, CancellationToken.None);
|
|
|
|
roster.Technicians.Select(t => t.ContactName).Should().BeEquivalentTo(new[] { "Busy", "Newcomer" });
|
|
}
|
|
|
|
using var verify = NewContext(dbName);
|
|
var busy = verify.Vendors.Single(v => v.Id == busyId);
|
|
busy.IsDeleted.Should().NotBeTrue();
|
|
busy.IsActive.Should().BeTrue();
|
|
verify.Vendors.Count(v => v.CompanyId == companyId && (v.IsDeleted == null || v.IsDeleted == false)).Should().Be(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTechnicians_StaleRowVersion_ThrowsConcurrencyAndLeavesCompanyUnchanged()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Race Co", InitialRowVersion(), "Original");
|
|
companyId = cid;
|
|
}
|
|
|
|
using (var concurrentWriter = NewContext(dbName))
|
|
{
|
|
var company = concurrentWriter.VendorCompanies.Single(c => c.Id == companyId);
|
|
company.Notes = "modified concurrently";
|
|
await concurrentWriter.SaveChangesAsync();
|
|
}
|
|
|
|
using (var act = NewContext(dbName))
|
|
{
|
|
var service = new VendorCompanyRosterDataService(act);
|
|
var actCall = () => service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
RowVersion = InitialRowVersion(),
|
|
ActorUserId = "42",
|
|
AddTechnicians = new List<RosterTechnicianWriteModel>
|
|
{
|
|
new() { ContactName = "Should Not Persist", IsActive = true }
|
|
}
|
|
}, CancellationToken.None);
|
|
|
|
await actCall.Should().ThrowAsync<DbUpdateConcurrencyException>();
|
|
}
|
|
|
|
// The stale save must not clobber the concurrent writer's company state. (The
|
|
// InMemory provider does not roll back applied inserts when the rowversion-guarded
|
|
// update fails; on SQL Server the implicit SaveChanges transaction makes the whole
|
|
// batch atomic — the same mechanism the PUT reconcile relies on.)
|
|
using var verify = NewContext(dbName);
|
|
var stored = verify.VendorCompanies.Single(c => c.Id == companyId);
|
|
stored.Notes.Should().Be("modified concurrently");
|
|
stored.RowVersion.Should().NotEqual(InitialRowVersion());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTechnicians_UnknownCompany_ThrowsKeyNotFound()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
using var context = NewContext(dbName);
|
|
var service = new VendorCompanyRosterDataService(context);
|
|
|
|
var act = () => service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
|
{
|
|
CompanyId = 404,
|
|
RowVersion = InitialRowVersion(),
|
|
ActorUserId = "42",
|
|
AddTechnicians = new List<RosterTechnicianWriteModel>
|
|
{
|
|
new() { ContactName = "Ghost", IsActive = true }
|
|
}
|
|
}, CancellationToken.None);
|
|
|
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
|
context.Vendors.Should().BeEmpty();
|
|
context.VendorCompanies.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTechnicians_WithCompanyFields_UpdatesOnlyProvidedFieldsAndPropagatesToExisting()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
int companyId, existingId;
|
|
using (var seed = NewContext(dbName))
|
|
{
|
|
var company = new VendorCompany
|
|
{
|
|
Name = "Field Co",
|
|
NormalizedName = "field co",
|
|
CompanyPhone = "(111) 111-0001",
|
|
Email = "keep@example.com",
|
|
Address = "Old Addr",
|
|
City = "OldCity",
|
|
Notes = "keep notes",
|
|
RowVersion = InitialRowVersion()
|
|
};
|
|
seed.VendorCompanies.Add(company);
|
|
seed.Vendors.Add(new Vendor
|
|
{
|
|
CompanyName = "Field Co",
|
|
ContactName = "Keeper",
|
|
IsActive = true,
|
|
IsDeleted = false,
|
|
Company = company,
|
|
CompanyPhone = "(111) 111-0001",
|
|
Address = "Old Addr",
|
|
City = "OldCity"
|
|
});
|
|
await seed.SaveChangesAsync();
|
|
companyId = company.Id;
|
|
existingId = seed.Vendors.First(v => v.CompanyId == companyId).Id;
|
|
}
|
|
|
|
using (var act = NewContext(dbName))
|
|
{
|
|
var service = new VendorCompanyRosterDataService(act);
|
|
await service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
RowVersion = InitialRowVersion(),
|
|
ActorUserId = "42",
|
|
CompanyFields = new VendorRosterCompanyFieldsWriteModel
|
|
{
|
|
Name = "Renamed Co",
|
|
CompanyPhone = "(222) 222-0002",
|
|
City = "NewCity"
|
|
},
|
|
AddTechnicians = new List<RosterTechnicianWriteModel>
|
|
{
|
|
new() { ContactName = "Fresh", IsActive = true }
|
|
}
|
|
}, CancellationToken.None);
|
|
}
|
|
|
|
using var verify = NewContext(dbName);
|
|
var companyRow = verify.VendorCompanies.Single(c => c.Id == companyId);
|
|
companyRow.Name.Should().Be("Renamed Co");
|
|
companyRow.NormalizedName.Should().Be("renamed co");
|
|
companyRow.CompanyPhone.Should().Be("(222) 222-0002");
|
|
companyRow.City.Should().Be("NewCity");
|
|
companyRow.Email.Should().Be("keep@example.com");
|
|
companyRow.Address.Should().Be("Old Addr");
|
|
companyRow.Notes.Should().Be("keep notes");
|
|
|
|
var existing = verify.Vendors.Single(v => v.Id == existingId);
|
|
existing.IsDeleted.Should().NotBeTrue();
|
|
existing.IsActive.Should().BeTrue();
|
|
existing.CompanyName.Should().Be("Renamed Co");
|
|
existing.CompanyPhone.Should().Be("(222) 222-0002");
|
|
existing.City.Should().Be("NewCity");
|
|
|
|
var added = verify.Vendors.Single(v => v.ContactName == "Fresh");
|
|
added.CompanyName.Should().Be("Renamed Co");
|
|
added.CompanyPhone.Should().Be("(222) 222-0002");
|
|
added.City.Should().Be("NewCity");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTechnicians_ForwardsCancellation()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
using var context = NewContext(dbName);
|
|
var service = new VendorCompanyRosterDataService(context);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
var act = () => service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
|
|
{
|
|
CompanyId = 1,
|
|
RowVersion = InitialRowVersion(),
|
|
ActorUserId = "42",
|
|
AddTechnicians = new List<RosterTechnicianWriteModel> { new() { ContactName = "T" } }
|
|
}, cts.Token);
|
|
|
|
await act.Should().ThrowAsync<OperationCanceledException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveRoster_AppliesAreaOnlyWhenProvidedAndReadsAreaName()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString();
|
|
using var context = NewContext(dbName);
|
|
context.Areas.AddRange(
|
|
new Area { Id = 1, Name = "East", NormalizedName = "east" },
|
|
new Area { Id = 2, Name = "Central", NormalizedName = "central" });
|
|
await context.SaveChangesAsync();
|
|
var (companyId, _) = await SeedCompanyWithTechnicianAsync(context, "Area Co", "Tech");
|
|
var service = new VendorCompanyRosterDataService(context);
|
|
|
|
VendorCompanyRosterWriteModel Snapshot(bool provided, int? areaId) => new()
|
|
{
|
|
CompanyId = companyId,
|
|
Name = "Area Co",
|
|
Email = "area@example.com",
|
|
AreaIdProvided = provided,
|
|
AreaId = areaId,
|
|
Technicians = context.Vendors.Where(v => v.CompanyId == companyId)
|
|
.Select(v => new RosterTechnicianWriteModel { Id = v.Id, ContactName = v.ContactName })
|
|
.ToList()
|
|
};
|
|
|
|
var assigned = await service.SaveRosterAsync(Snapshot(true, 2), CancellationToken.None);
|
|
assigned.AreaId.Should().Be(2);
|
|
assigned.AreaName.Should().Be("Central");
|
|
|
|
var untouched = await service.SaveRosterAsync(Snapshot(false, null), CancellationToken.None);
|
|
untouched.AreaId.Should().Be(2);
|
|
|
|
var cleared = await service.SaveRosterAsync(Snapshot(true, null), CancellationToken.None);
|
|
cleared.AreaId.Should().BeNull();
|
|
cleared.AreaName.Should().BeNull();
|
|
|
|
(await service.IsActiveAreaAsync(1, CancellationToken.None)).Should().BeTrue();
|
|
(await service.IsActiveAreaAsync(42, CancellationToken.None)).Should().BeFalse();
|
|
}
|
|
}
|