mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
642 lines
28 KiB
C#
642 lines
28 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Api.SeaHavenIndustries.Controllers;
|
|
using Data.SeaHavenIndustries;
|
|
using FluentAssertions;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Migrations;
|
|
using Microsoft.EntityFrameworkCore.Migrations.Operations;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using SeaHaven.DataServices.Implementation;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Implementation;
|
|
using SeaHaven.Services.Validation;
|
|
using Xunit;
|
|
|
|
namespace Api.SeaHavenIndustries.Tests;
|
|
|
|
public class LocationSiteContactsTests
|
|
{
|
|
private static ApplicationDbContext NewContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
return new ApplicationDbContext(options);
|
|
}
|
|
|
|
private static LocationService NewService(ApplicationDbContext ctx) =>
|
|
new(
|
|
new LocationDataService(ctx),
|
|
new AccountDataService(ctx),
|
|
new CreateLocationValidation(),
|
|
new UpdateLocationValidation());
|
|
|
|
private static ClaimsPrincipal OrgWideAdmin()
|
|
{
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.NameIdentifier, "admin-1"),
|
|
new(ClaimTypes.Role, "Admin"),
|
|
new(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)
|
|
};
|
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
|
}
|
|
|
|
private static async Task<Locations> SeedLocationWithContactsAsync(ApplicationDbContext ctx)
|
|
{
|
|
var service = NewService(ctx);
|
|
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
|
{
|
|
Name = "Depot",
|
|
AccountId = null,
|
|
Contacts = new List<SiteContactRequestDTO>
|
|
{
|
|
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
|
|
new() { Name = "Bob Dillon", Phone = "555-0200"}
|
|
}
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
return ctx.Locations.Include(l => l.Contacts).Single();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WithContacts_PersistsTrimmedOrderedRows_MirrorsFirstPhone_SetsAccountFromLocation()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
|
|
await ctx.SaveChangesAsync();
|
|
|
|
await NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
|
{
|
|
Name = "Warehouse",
|
|
AccountId = 7,
|
|
Contacts = new List<SiteContactRequestDTO>
|
|
{
|
|
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
|
|
new() { Name = "Bob Dillon", Phone = "555-0200" }
|
|
}
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
var location = ctx.Locations.Include(l => l.Contacts).Single();
|
|
location.PhoneNumber.Should().Be("555-0100", "first contact mirrors Location.PhoneNumber");
|
|
|
|
var contacts = location.Contacts.OrderBy(c => c.SiteContactOrder).ToList();
|
|
contacts.Should().HaveCount(2);
|
|
contacts[0].FirstName.Should().Be("Alice Cooper");
|
|
contacts[0].MiddleName.Should().BeNull();
|
|
contacts[0].LastName.Should().BeNull();
|
|
contacts[0].PhoneNumber.Should().Be("555-0100");
|
|
contacts[0].SiteContactOrder.Should().Be(0);
|
|
contacts[0].AccountId.Should().Be(7, "AccountId must come from the location");
|
|
contacts[0].LocationId.Should().Be(location.Id);
|
|
contacts[0].createdby.Should().Be("admin-1");
|
|
contacts[0].CreatedDate.Should().NotBeNull();
|
|
contacts[1].SiteContactOrder.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_EmptyContactsArray_IsExplicitValidationError_AndWritesNothing()
|
|
{
|
|
using var ctx = NewContext();
|
|
|
|
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
|
{
|
|
Name = "Warehouse",
|
|
Contacts = new List<SiteContactRequestDTO>()
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
|
.Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts");
|
|
ctx.Locations.Should().BeEmpty();
|
|
ctx.Contacts.Should().BeEmpty();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null, "555-0100", "Contact name is required.")]
|
|
[InlineData(" ", "555-0100", "Contact name is required.")]
|
|
[InlineData("Alice", null, "Contact phone is required.")]
|
|
[InlineData("Alice", " ", "Contact phone is required.")]
|
|
[InlineData("Alice", "555-0100", null)]
|
|
public async Task Create_ValidatesEachRow(string? name, string? phone, string? expectedError)
|
|
{
|
|
using var ctx = NewContext();
|
|
var contacts = new List<SiteContactRequestDTO> { new() { Name = name, Phone = phone } };
|
|
|
|
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
|
{
|
|
Name = "Warehouse",
|
|
Contacts = contacts
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
if (expectedError == null)
|
|
{
|
|
await act.Should().NotThrowAsync();
|
|
return;
|
|
}
|
|
|
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
|
.Which.Errors.Should().Contain(e => e.ErrorMessage == expectedError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_RejectsOversizedNameAndPhone()
|
|
{
|
|
using var ctx = NewContext();
|
|
|
|
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
|
{
|
|
Name = "Warehouse",
|
|
Contacts = new List<SiteContactRequestDTO>
|
|
{
|
|
new() { Name = new string('x', 101), Phone = new string('5', 21) }
|
|
}
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
var thrown = (await act.Should().ThrowAsync<FluentValidation.ValidationException>()).Which;
|
|
thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 100"));
|
|
thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 20"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_RejectsMoreThanTwentyContacts()
|
|
{
|
|
using var ctx = NewContext();
|
|
var contacts = Enumerable.Range(1, 21)
|
|
.Select(i => new SiteContactRequestDTO { Name = $"C{i}", Phone = "555-0100" })
|
|
.ToList();
|
|
|
|
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
|
{
|
|
Name = "Warehouse",
|
|
Contacts = contacts
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
|
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("cannot exceed 20"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_ContactsNull_KeepsLegacyBehavior_AndDoesNotMutateContactRows()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
var before = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
|
|
|
|
await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
|
|
{
|
|
Name = "Depot",
|
|
Phone = "555-9999"
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
var after = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
|
|
after.Should().HaveCount(before.Count);
|
|
after[0].FirstName.Should().Be(before[0].FirstName);
|
|
after[0].PhoneNumber.Should().Be(before[0].PhoneNumber);
|
|
after[0].SiteContactOrder.Should().Be(before[0].SiteContactOrder);
|
|
after[0].IsDeleted.Should().Be(before[0].IsDeleted);
|
|
ctx.Locations.Single().PhoneNumber.Should().Be("555-9999", "legacy updates keep request.Phone");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_EmptyContactsArray_IsExplicitValidationError()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
|
|
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
|
|
{
|
|
Name = "Depot",
|
|
Contacts = new List<SiteContactRequestDTO>()
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
|
.Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts");
|
|
ctx.Contacts.Should().OnlyContain(c => c.IsDeleted != true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_AndUpdatesMirror()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
var alice = seeded.Contacts.Single(c => c.FirstName == "Alice Cooper");
|
|
var bob = seeded.Contacts.Single(c => c.FirstName == "Bob Dillon");
|
|
|
|
await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
|
|
{
|
|
Name = "Depot",
|
|
Contacts = new List<SiteContactRequestDTO>
|
|
{
|
|
new() { Id = bob.Id, Name = "Bob Dillon", Phone = "555-0200" },
|
|
new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0300" },
|
|
new() { Name = "Cara Lane", Phone = "555-0400" }
|
|
}
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
var location = ctx.Locations.Include(l => l.Contacts).Single();
|
|
location.PhoneNumber.Should().Be("555-0200", "reorder must update the mirror to the new first contact");
|
|
|
|
var active = location.Contacts.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder).ToList();
|
|
active.Should().HaveCount(3);
|
|
active.Select(c => c.FirstName).Should().ContainInOrder("Bob Dillon", "Alice Cooper", "Cara Lane");
|
|
active.Select(c => c.SiteContactOrder).Should().ContainInOrder(new int?[] { 0, 1, 2 }, "order is densified from zero");
|
|
active[1].PhoneNumber.Should().Be("555-0300");
|
|
active[0].Id.Should().Be(bob.Id);
|
|
active[1].Id.Should().Be(alice.Id, "existing ids are preserved");
|
|
active[2].AccountId.Should().Be(location.AccountId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_SoftDeletesRemovedContacts_WithAuditFields_KeepingRowsReadable()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
var alice = seeded.Contacts.Single(c => c.FirstName == "Alice Cooper");
|
|
var bob = seeded.Contacts.Single(c => c.FirstName == "Bob Dillon");
|
|
|
|
await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
|
|
{
|
|
Name = "Depot",
|
|
Contacts = new List<SiteContactRequestDTO> { new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" } }
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
var removed = ctx.Contacts.AsNoTracking().Single(c => c.Id == bob.Id);
|
|
removed.IsDeleted.Should().BeTrue();
|
|
removed.DeleterUserId.Should().Be("admin-1");
|
|
removed.DeletionTime.Should().NotBeNull();
|
|
removed.LastModificationTime.Should().NotBeNull();
|
|
removed.FirstName.Should().Be("Bob Dillon", "soft deleted rows keep data so historical WorkOrderContacts still render");
|
|
removed.PhoneNumber.Should().Be("555-0200");
|
|
|
|
var kept = ctx.Contacts.AsNoTracking().Single(c => c.Id == alice.Id);
|
|
kept.IsDeleted.Should().NotBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_ForeignLocationContactId_RejectedWithoutMutation()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
ctx.Locations.Add(new Locations { Id = seeded.Id + 1, Name = "Other" });
|
|
ctx.Contacts.Add(new Contacts { Id = 901, LocationId = seeded.Id + 1, FirstName = "Foreign", PhoneNumber = "555-0900" });
|
|
await ctx.SaveChangesAsync();
|
|
var snapshot = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
|
|
|
|
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
|
|
{
|
|
Name = "Depot",
|
|
Contacts = new List<SiteContactRequestDTO>
|
|
{
|
|
new() { Name = "Alice Cooper", Phone = "555-0100" },
|
|
new() { Id = 901, Name = "Foreign", Phone = "555-0900" }
|
|
}
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
|
.Which.Errors.Should().ContainSingle(e => e.PropertyName.Contains("Id"));
|
|
|
|
var after = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
|
|
after.Should().HaveCount(snapshot.Count);
|
|
after.Should().BeEquivalentTo(snapshot, o => o.Excluding(c => c.Location));
|
|
ctx.Locations.AsNoTracking().Single(l => l.Id == seeded.Id).PhoneNumber
|
|
.Should().Be("555-0100", "the rejected update must not persist any change");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_DeletedContactId_Rejected()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
var bob = seeded.Contacts.Single(c => c.FirstName == "Bob Dillon");
|
|
bob.IsDeleted = true;
|
|
await ctx.SaveChangesAsync();
|
|
|
|
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
|
|
{
|
|
Name = "Depot",
|
|
Contacts = new List<SiteContactRequestDTO> { new() { Id = bob.Id, Name = "Bob Dillon", Phone = "555-0200" } }
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
|
.Which.Errors.Should().ContainSingle(e => e.PropertyName.Contains("Id"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_AccountLevelContactId_Rejected()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
ctx.Contacts.Add(new Contacts { Id = 902, AccountId = 55, LocationId = null, FirstName = "AccountOnly", PhoneNumber = "555-0900" });
|
|
await ctx.SaveChangesAsync();
|
|
|
|
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
|
|
{
|
|
Name = "Depot",
|
|
Contacts = new List<SiteContactRequestDTO> { new() { Id = 902, Name = "AccountOnly", Phone = "555-0900" } }
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
|
.Which.Errors.Should().ContainSingle(e => e.PropertyName.Contains("Id"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_DuplicateContactIds_Rejected()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
var alice = seeded.Contacts.Single(c => c.FirstName == "Alice Cooper");
|
|
|
|
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
|
|
{
|
|
Name = "Depot",
|
|
Contacts = new List<SiteContactRequestDTO>
|
|
{
|
|
new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" },
|
|
new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" }
|
|
}
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
|
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("more than once"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_AccountScopedCaller_CannotAttachContactsToForeignLocation()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
seeded.AccountId = 99;
|
|
await ctx.SaveChangesAsync();
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.NameIdentifier, "actor-1"),
|
|
new(ClaimTypes.Role, "Dispatcher"),
|
|
new(SeaHavenClaimTypes.AccountId, "4")
|
|
};
|
|
var caller = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
|
|
|
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
|
|
{
|
|
Name = "Depot",
|
|
Contacts = new List<SiteContactRequestDTO> { new() { Name = "Nope", Phone = "555-0000" } }
|
|
}, caller, CancellationToken.None);
|
|
|
|
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
|
ctx.Contacts.Should().OnlyContain(c => c.IsDeleted != true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Detail_ExcludesDeletedContacts_AndOrdersBySiteContactOrderThenId()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
var alice = seeded.Contacts.Single(c => c.FirstName == "Alice Cooper");
|
|
var bob = seeded.Contacts.Single(c => c.FirstName == "Bob Dillon");
|
|
|
|
await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
|
|
{
|
|
Name = "Depot",
|
|
Contacts = new List<SiteContactRequestDTO> { new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" } }
|
|
}, OrgWideAdmin(), CancellationToken.None);
|
|
|
|
var detail = await NewService(ctx).GetLocationDetailAsync(seeded.Id, CancellationToken.None);
|
|
|
|
detail!.Contacts.Should().ContainSingle().Which.Id.Should().Be(alice.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Detail_OrdersContactsByOrderThenId_WhenOrdersAreMissing()
|
|
{
|
|
using var ctx = NewContext();
|
|
var loc = new Locations { Id = 5, Name = "Legacy", AccountId = 7 };
|
|
ctx.Locations.Add(loc);
|
|
ctx.Contacts.AddRange(
|
|
new Contacts { Id = 31, LocationId = 5, AccountId = 7, FirstName = "No Order B", PhoneNumber = "555-2" },
|
|
new Contacts { Id = 30, LocationId = 5, AccountId = 7, FirstName = "No Order A", PhoneNumber = "555-1" });
|
|
await ctx.SaveChangesAsync();
|
|
|
|
var detail = await NewService(ctx).GetLocationDetailAsync(5, CancellationToken.None);
|
|
|
|
detail!.Contacts!.Select(c => c.Name).Should().ContainInOrder("No Order A", "No Order B");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_LoadsPageContactsInSingleBatchedRead()
|
|
{
|
|
var data = new Mock<ILocationDataService>();
|
|
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
|
.ReturnsAsync((new List<Locations>
|
|
{
|
|
new() { Id = 1, Name = "One" },
|
|
new() { Id = 2, Name = "Two" }
|
|
}, 2));
|
|
data.Setup(d => d.GetSiteContactsByLocationIdsAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Contacts>
|
|
{
|
|
new() { Id = 10, LocationId = 1, SiteContactOrder = 1, FirstName = "Second", PhoneNumber = "555-2" },
|
|
new() { Id = 11, LocationId = 1, SiteContactOrder = 0, FirstName = "First", PhoneNumber = "555-1" },
|
|
new() { Id = 12, LocationId = 2, SiteContactOrder = 0, FirstName = "Solo", PhoneNumber = "555-3" }
|
|
});
|
|
|
|
var service = new LocationService(
|
|
data.Object,
|
|
Mock.Of<IAccountDataService>(),
|
|
new CreateLocationValidation(),
|
|
new UpdateLocationValidation());
|
|
|
|
var page = await service.GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
|
|
|
|
page.Items.Should().HaveCount(2);
|
|
page.Items.Single(i => i.Id == 1).Contacts!.Select(c => c.Name)
|
|
.Should().ContainInOrder(new[] { "First", "Second" }, "contacts are ordered per location");
|
|
page.Items.Single(i => i.Id == 2).Contacts!.Select(c => c.Name).Should().ContainSingle("Solo");
|
|
data.Verify(d => d.GetSiteContactsByLocationIdsAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
data.Verify(d => d.GetSiteContactsByLocationIdsAsync(It.Is<IReadOnlyCollection<int>>(ids => ids.Contains(1) && ids.Contains(2)), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DataService_GetSiteContactsByLocationIdsAsync_ExcludesDeleted_AndOrders()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.Contacts.AddRange(
|
|
new Contacts { Id = 1, LocationId = 5, SiteContactOrder = 1, FirstName = "B" },
|
|
new Contacts { Id = 2, LocationId = 5, SiteContactOrder = 0, FirstName = "A" },
|
|
new Contacts { Id = 3, LocationId = 5, SiteContactOrder = 0, FirstName = "Tie", IsDeleted = true },
|
|
new Contacts { Id = 4, LocationId = 6, SiteContactOrder = 0, FirstName = "Other" });
|
|
await ctx.SaveChangesAsync();
|
|
|
|
var rows = await new LocationDataService(ctx).GetSiteContactsByLocationIdsAsync(new[] { 5 }, CancellationToken.None);
|
|
|
|
rows.Select(r => r.FirstName).Should().ContainInOrder("A", "B");
|
|
rows.Should().OnlyContain(r => r.IsDeleted != true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DataService_GetSiteContactsByLocationIdsAsync_EmptyInput_ReturnsWithoutQuerying()
|
|
{
|
|
using var ctx = NewContext();
|
|
ctx.Contacts.Add(new Contacts { Id = 1, LocationId = 5, FirstName = "A" });
|
|
await ctx.SaveChangesAsync();
|
|
|
|
var rows = await new LocationDataService(ctx).GetSiteContactsByLocationIdsAsync(Array.Empty<int>(), CancellationToken.None);
|
|
|
|
rows.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DataService_DeleteByIdAsync_WithContacts_RemovesLocation_RetainsSoftDeletedDetachedContacts()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
var alice = seeded.Contacts!.Single(c => c.FirstName == "Alice Cooper");
|
|
var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon");
|
|
|
|
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None);
|
|
|
|
deleted.Should().BeTrue();
|
|
ctx.Locations.Should().BeEmpty();
|
|
|
|
var retained = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
|
|
retained.Should().HaveCount(2);
|
|
retained.Should().OnlyContain(c => c.IsDeleted == true);
|
|
retained.Should().OnlyContain(c => c.LocationId == null);
|
|
retained.Should().OnlyContain(c => c.DeletionTime != null);
|
|
retained.Should().OnlyContain(c => c.DeleterUserId == null, "the delete interface carries no actor");
|
|
retained.Single(c => c.Id == alice.Id).FirstName.Should().Be("Alice Cooper");
|
|
retained.Single(c => c.Id == alice.Id).PhoneNumber.Should().Be("555-0100");
|
|
retained.Single(c => c.Id == bob.Id).FirstName.Should().Be("Bob Dillon");
|
|
retained.Single(c => c.Id == bob.Id).PhoneNumber.Should().Be("555-0200");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DataService_DeleteByIdAsync_AlreadySoftDeletedContact_IsDetachedWithoutAuditRestamp()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon");
|
|
bob.IsDeleted = true;
|
|
bob.DeleterUserId = "admin-1";
|
|
bob.DeletionTime = new DateTime(2026, 1, 1);
|
|
await ctx.SaveChangesAsync();
|
|
var bobStamp = bob.DeletionTime;
|
|
|
|
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None);
|
|
|
|
deleted.Should().BeTrue();
|
|
ctx.Locations.Should().BeEmpty();
|
|
var retained = ctx.Contacts.AsNoTracking().Single(c => c.Id == bob.Id);
|
|
retained.LocationId.Should().BeNull("previously soft-deleted rows must also detach or the restrict FK blocks the delete");
|
|
retained.IsDeleted.Should().BeTrue();
|
|
retained.DeleterUserId.Should().Be("admin-1", "original audit stamp is preserved");
|
|
retained.DeletionTime.Should().Be(bobStamp);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DataService_DeleteByIdAsync_Missing_ReturnsFalse()
|
|
{
|
|
using var ctx = NewContext();
|
|
await SeedLocationWithContactsAsync(ctx);
|
|
|
|
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(424242, CancellationToken.None);
|
|
|
|
deleted.Should().BeFalse();
|
|
ctx.Locations.Should().HaveCount(1);
|
|
ctx.Contacts.Should().HaveCount(2);
|
|
}
|
|
|
|
private sealed class ExposedSH138Migration : Data.SeaHavenIndustries.Migrations.SH138_SiteContacts
|
|
{
|
|
public void UpExposed(MigrationBuilder builder) => Up(builder);
|
|
public void DownExposed(MigrationBuilder builder) => Down(builder);
|
|
}
|
|
|
|
[Fact]
|
|
public void Migration_SH138_IsAdditiveOnly()
|
|
{
|
|
var migration = new ExposedSH138Migration();
|
|
|
|
var up = new MigrationBuilder("SqlServer");
|
|
migration.UpExposed(up);
|
|
|
|
up.Operations.Should().ContainSingle("the migration must only add the SiteContactOrder column");
|
|
var column = up.Operations.Single().Should().BeOfType<AddColumnOperation>().Subject;
|
|
column.Table.Should().Be("Contacts");
|
|
column.Name.Should().Be("SiteContactOrder");
|
|
column.IsNullable.Should().BeTrue("existing rows and legacy contacts have no order");
|
|
column.ClrType.Should().Be(typeof(int));
|
|
|
|
var down = new MigrationBuilder("SqlServer");
|
|
migration.DownExposed(down);
|
|
|
|
down.Operations.Should().ContainSingle("reverting drops only the added column");
|
|
down.Operations.Single().Should().BeOfType<DropColumnOperation>();
|
|
}
|
|
|
|
[Fact]
|
|
public void Migration_SH138_HasNoReorderIndexOrFkChange()
|
|
{
|
|
var migration = new ExposedSH138Migration();
|
|
var up = new MigrationBuilder("SqlServer");
|
|
migration.UpExposed(up);
|
|
|
|
up.Operations.Should().NotContain(o => o is CreateIndexOperation, "no unique reorder index");
|
|
up.Operations.Should().NotContain(o => o is AddForeignKeyOperation || o is DropForeignKeyOperation, "the existing restrict relationship is unchanged");
|
|
up.Operations.Should().NotContain(o => o is DropColumnOperation || o is DropTableOperation || o is SqlOperation, "no destructive or backfill operations");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Controller_Detail_ProjectsContactsAndCompatibilityFields()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
var controller = new LocationController(
|
|
NewService(ctx),
|
|
Mock.Of<ILogger<LocationController>>())
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
|
};
|
|
|
|
var result = await controller.GetLocationById(seeded.Id, CancellationToken.None);
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
using var json = JsonSerializer.SerializeToDocument(ok.Value);
|
|
var root = json.RootElement;
|
|
|
|
root.GetProperty("Phone").GetString().Should().Be("555-0100", "Phone mirrors the first site contact");
|
|
root.GetProperty("Contact").GetString().Should().Be("Alice Cooper", "Contact is the first contact display name");
|
|
var contacts = root.GetProperty("Contacts");
|
|
contacts.GetArrayLength().Should().Be(2);
|
|
contacts[0].GetProperty("Name").GetString().Should().Be("Alice Cooper");
|
|
contacts[0].GetProperty("Phone").GetString().Should().Be("555-0100");
|
|
contacts[1].GetProperty("Name").GetString().Should().Be("Bob Dillon");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Controller_List_ProjectsFirstContactAsLegacyContactField()
|
|
{
|
|
using var ctx = NewContext();
|
|
var seeded = await SeedLocationWithContactsAsync(ctx);
|
|
var controller = new LocationController(
|
|
NewService(ctx),
|
|
Mock.Of<ILogger<LocationController>>())
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
|
};
|
|
|
|
var result = await controller.GetLocationList(cancellationToken: CancellationToken.None);
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
using var json = JsonSerializer.SerializeToDocument(ok.Value);
|
|
var row = json.RootElement.GetProperty("Data").EnumerateArray().Single();
|
|
|
|
row.GetProperty("Contact").GetString().Should().Be("Alice Cooper");
|
|
row.GetProperty("Phone").GetString().Should().Be("555-0100");
|
|
row.GetProperty("Contacts").GetArrayLength().Should().Be(2);
|
|
}
|
|
}
|