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

643 lines
28 KiB
C#
Raw Normal View History

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);
}
}