diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/AddressInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/AddressInfoDataProvider.cs index adae496d40..cb76802ce5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/AddressInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/AddressInfoDataProvider.cs @@ -5,7 +5,6 @@ using Unity.GrantManager.ApplicantProfile.ProfileData; using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; -using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; @@ -102,7 +101,7 @@ from application in apps.DefaultIfEmpty() var addressDtos = deduplicated.Select(r => new AddressInfoItemDto { Id = r.address.Id, - AddressType = GetAddressTypeName(r.address.AddressType), + AddressType = AddressTypeMapper.ToDisplayName(r.address.AddressType), Street = r.address.Street ?? string.Empty, Street2 = r.address.Street2 ?? string.Empty, Unit = r.address.Unit ?? string.Empty, @@ -110,15 +109,22 @@ from application in apps.DefaultIfEmpty() Province = r.address.Province ?? string.Empty, PostalCode = r.address.Postal ?? string.Empty, Country = r.address.Country ?? string.Empty, - IsPrimary = r.address.HasProperty(AddressExtraPropertyNames.IsPrimary) && r.address.GetProperty(AddressExtraPropertyNames.IsPrimary), + IsPrimary = r.address.IsFlaggedPrimary(), IsEditable = r.IsFromApplicantPath && !distinctApplicants, ReferenceNo = r.ReferenceNo }).ToList(); - // If no address is marked as primary, mark the most recent one as primary - if (addressDtos.Count > 0 && !addressDtos.Any(a => a.IsPrimary)) + // Primary is scoped to the address type WITHIN an applicant: this provider can return + // addresses for more than one applicant, so grouping by type alone would let one + // applicant's flagged primary suppress the fallback for another. + foreach (var typeGroup in deduplicated.GroupBy(r => new { r.address.ApplicantId, r.address.AddressType })) { - var mostRecent = deduplicated.OrderByDescending(r => r.CreationTime).First(); + if (typeGroup.Any(r => r.address.IsFlaggedPrimary())) + { + continue; + } + + var mostRecent = typeGroup.OrderByDescending(r => r.CreationTime).First(); var mostRecentDto = addressDtos.First(a => a.Id == mostRecent.address.Id); mostRecentDto.IsPrimary = true; } @@ -128,19 +134,5 @@ from application in apps.DefaultIfEmpty() return dto; } - - /// - /// Maps an enum value to a human-readable display name. - /// - private static string GetAddressTypeName(AddressType addressType) - { - return addressType switch - { - AddressType.PhysicalAddress => "Physical", - AddressType.MailingAddress => "Mailing", - AddressType.BusinessAddress => "Business", - _ => addressType.ToString() - }; - } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs index 5c97ac4481..e323cc0935 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs @@ -14,6 +14,7 @@ namespace Unity.GrantManager.GrantsPortal.Handlers; public class AddressCreateHandler( IApplicantAddressRepository applicantAddressRepository, + IApplicantAddressManager applicantAddressManager, ILogger logger) : IPortalCommandHandler, ITransientDependency { public string DataType => "ADDRESS_CREATE_COMMAND"; @@ -51,28 +52,21 @@ public virtual async Task HandleAsync(PluginDataPayload payload) Province = innerData.Province, Postal = innerData.PostalCode, Country = innerData.Country, - AddressType = MapAddressType(innerData.AddressType) + AddressType = AddressTypeMapper.FromPortalValue(innerData.AddressType) }; EntityHelper.TrySetId(address, () => addressId); address.SetProperty(AddressExtraPropertyNames.ProfileId, profileId.ToString()); - address.SetProperty(AddressExtraPropertyNames.IsPrimary, innerData.IsPrimary); + address.SetPrimaryFlag(innerData.IsPrimary); - // Demote existing primary addresses for the same applicant if (innerData.IsPrimary) { - var siblingAddresses = await applicantAddressRepository.FindByApplicantIdAsync(innerData.ApplicantId); - - foreach (var sibling in siblingAddresses) - { - if (!sibling.HasProperty(AddressExtraPropertyNames.IsPrimary)) continue; - if (!sibling.GetProperty(AddressExtraPropertyNames.IsPrimary)) continue; - - var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); - trackedSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, false); - await applicantAddressRepository.UpdateAsync(trackedSibling); - } + // Primary is scoped to the address type, so only same-type siblings are demoted. + await applicantAddressManager.DemotePrimarySiblingsAsync( + innerData.ApplicantId, + address.AddressType, + addressId); } await applicantAddressRepository.InsertAsync(address); @@ -80,15 +74,4 @@ public virtual async Task HandleAsync(PluginDataPayload payload) logger.LogInformation("Address {AddressId} created successfully", addressId); return "Address created successfully"; } - - private static AddressType MapAddressType(string? portalAddressType) - { - return portalAddressType?.ToUpperInvariant() switch - { - "MAILING" => AddressType.MailingAddress, - "PHYSICAL" => AddressType.PhysicalAddress, - "BUSINESS" => AddressType.BusinessAddress, - _ => AddressType.PhysicalAddress - }; - } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressDeleteHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressDeleteHandler.cs index a45a2c8a49..246734a14d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressDeleteHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressDeleteHandler.cs @@ -10,6 +10,7 @@ namespace Unity.GrantManager.GrantsPortal.Handlers; public class AddressDeleteHandler( IApplicantAddressRepository applicantAddressRepository, + IApplicantAddressManager applicantAddressManager, ILogger logger) : IPortalCommandHandler, ITransientDependency { public string DataType => "ADDRESS_DELETE_COMMAND"; @@ -24,7 +25,17 @@ public virtual async Task HandleAsync(PluginDataPayload payload) var address = await applicantAddressRepository.FindAsync(addressId); if (address != null) { + var wasPrimary = address.IsFlaggedPrimary(); + var addressType = address.AddressType; + var applicantId = address.ApplicantId; + await applicantAddressRepository.DeleteAsync(address); + + if (wasPrimary && applicantId.HasValue) + { + // The address type group just lost its primary, so promote the most recent survivor. + await applicantAddressManager.ElectPrimaryAsync(applicantId.Value, addressType, addressId); + } } logger.LogInformation("Address {AddressId} deleted successfully", addressId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs index 01f6010d0a..9f388f42d5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs @@ -5,7 +5,6 @@ using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantsPortal.Messages; using Unity.GrantManager.GrantsPortal.Messages.Commands; -using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Uow; @@ -13,6 +12,7 @@ namespace Unity.GrantManager.GrantsPortal.Handlers; public class AddressEditHandler( IApplicantAddressRepository applicantAddressRepository, + IApplicantAddressManager applicantAddressManager, ILogger logger) : IPortalCommandHandler, ITransientDependency { public string DataType => "ADDRESS_EDIT_COMMAND"; @@ -28,6 +28,9 @@ public virtual async Task HandleAsync(PluginDataPayload payload) var address = await applicantAddressRepository.GetAsync(addressId); + var previousAddressType = address.AddressType; + var wasPrimary = address.IsFlaggedPrimary(); + address.Street = innerData.Street; address.Street2 = innerData.Street2; address.Unit = innerData.Unit; @@ -35,45 +38,46 @@ public virtual async Task HandleAsync(PluginDataPayload payload) address.Province = innerData.Province; address.Postal = innerData.PostalCode; address.Country = innerData.Country; - address.AddressType = MapAddressType(innerData.AddressType); + address.AddressType = AddressTypeMapper.FromPortalValue(innerData.AddressType); + address.SetPrimaryFlag(innerData.IsPrimary); - if (innerData.IsPrimary && address.ApplicantId.HasValue) + if (address.ApplicantId.HasValue) { - await DemoteSiblingPrimaryAddressesAsync(address.ApplicantId.Value, addressId); + await ApplyPrimaryScopeAsync( + address.ApplicantId.Value, + addressId, + previousAddressType, + address.AddressType, + wasPrimary, + innerData.IsPrimary); } - address.SetProperty(AddressExtraPropertyNames.IsPrimary, innerData.IsPrimary); - await applicantAddressRepository.UpdateAsync(address); logger.LogInformation("Address {AddressId} updated successfully", addressId); return "Address updated successfully"; } - private async Task DemoteSiblingPrimaryAddressesAsync(Guid applicantId, Guid excludeAddressId) + /// + /// Keeps the "at most one primary per address type" invariant intact after an edit. + /// An address that moves to another type contests its new group and vacates the old one. + /// + private async Task ApplyPrimaryScopeAsync( + Guid applicantId, + Guid addressId, + AddressType previousAddressType, + AddressType currentAddressType, + bool wasPrimary, + bool isPrimary) { - var siblingAddresses = await applicantAddressRepository.FindByApplicantIdAsync(applicantId); - - foreach (var sibling in siblingAddresses) + if (isPrimary) { - if (sibling.Id == excludeAddressId) continue; - if (!sibling.HasProperty(AddressExtraPropertyNames.IsPrimary)) continue; - if (!sibling.GetProperty(AddressExtraPropertyNames.IsPrimary)) continue; - - var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); - trackedSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, false); - await applicantAddressRepository.UpdateAsync(trackedSibling); + await applicantAddressManager.DemotePrimarySiblingsAsync(applicantId, currentAddressType, addressId); } - } - private static AddressType MapAddressType(string? portalAddressType) - { - return portalAddressType?.ToUpperInvariant() switch + if (wasPrimary && previousAddressType != currentAddressType) { - "MAILING" => AddressType.MailingAddress, - "PHYSICAL" => AddressType.PhysicalAddress, - "BUSINESS" => AddressType.BusinessAddress, - _ => AddressType.PhysicalAddress - }; + await applicantAddressManager.ElectPrimaryAsync(applicantId, previousAddressType, addressId); + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs index 0496e683f4..4fd15b8428 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs @@ -12,7 +12,8 @@ namespace Unity.GrantManager.GrantsPortal.Handlers; public class AddressSetPrimaryHandler( IApplicantAddressRepository applicantAddressRepository, - ILogger logger) + IApplicantAddressManager applicantAddressManager, + ILogger logger) : IPortalCommandHandler, ITransientDependency { public string DataType => "ADDRESS_SET_PRIMARY_COMMAND"; @@ -28,27 +29,24 @@ public virtual async Task HandleAsync(PluginDataPayload payload) var address = await applicantAddressRepository.GetAsync(addressId); address.SetProperty(AddressExtraPropertyNames.ProfileId, profileId.ToString()); - address.SetProperty(AddressExtraPropertyNames.IsPrimary, true); + address.SetPrimaryFlag(true); if (address.ApplicantId.HasValue) { - var siblingAddresses = await applicantAddressRepository.FindByApplicantIdAsync(address.ApplicantId.Value); - - foreach (var sibling in siblingAddresses) - { - if (sibling.Id == addressId) continue; - if (!sibling.HasProperty(AddressExtraPropertyNames.IsPrimary)) continue; - if (!sibling.GetProperty(AddressExtraPropertyNames.IsPrimary)) continue; - - var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); - trackedSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, false); - await applicantAddressRepository.UpdateAsync(trackedSibling); - } + // Primary is scoped to the address type, so only same-type siblings are demoted. + await applicantAddressManager.DemotePrimarySiblingsAsync( + address.ApplicantId.Value, + address.AddressType, + addressId); } await applicantAddressRepository.UpdateAsync(address); - logger.LogInformation("Address {AddressId} set as primary", addressId); + logger.LogInformation( + "Address {AddressId} set as primary for address type {AddressType}", + addressId, + address.AddressType); + return "Address set as primary"; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AddressTypeMapper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AddressTypeMapper.cs new file mode 100644 index 0000000000..d2205f6758 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AddressTypeMapper.cs @@ -0,0 +1,62 @@ +using System; +using System.Linq; + +namespace Unity.GrantManager.GrantApplications; + +/// +/// Single place that translates between the enum and the +/// short names exchanged with the applicant portal (for example "Mailing"). +/// The translation is derived from the enum members themselves, so adding a new +/// requires no change here. +/// +public static class AddressTypeMapper +{ + /// + /// Suffix carried by every member name; stripped to form the short name. + /// + private const string MemberNameSuffix = "Address"; + + /// + /// Value used when the portal supplies no address type, or one that is not recognised. + /// + public const AddressType DefaultAddressType = AddressType.PhysicalAddress; + + /// + /// Maps a short address type name supplied by the portal (case-insensitive, for example + /// "MAILING") to its member, falling back to + /// when the value is missing or unknown. + /// + public static AddressType FromPortalValue(string? portalAddressType) + { + if (string.IsNullOrWhiteSpace(portalAddressType)) + { + return DefaultAddressType; + } + + var candidate = portalAddressType.Trim(); + + // DefaultIfEmpty carries the fallback: AddressType has no zero member, so + // FirstOrDefault would yield an undefined value rather than the intended default. + return Enum.GetValues() + .Where(addressType => string.Equals(ToDisplayName(addressType), candidate, StringComparison.OrdinalIgnoreCase)) + .DefaultIfEmpty(DefaultAddressType) + .First(); + } + + /// + /// Maps an member to its human-readable short name + /// (for example becomes "Mailing"). + /// + public static string ToDisplayName(AddressType addressType) + { + var memberName = addressType.ToString(); + + if (memberName.Length > MemberNameSuffix.Length + && memberName.EndsWith(MemberNameSuffix, StringComparison.Ordinal)) + { + return memberName[..^MemberNameSuffix.Length]; + } + + return memberName; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs index d06415e6ca..3b1c7fe2c4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Text.Json.Serialization; using Unity.GrantManager.GrantApplications; +using Volo.Abp.Data; using Volo.Abp.Domain.Entities.Auditing; using Volo.Abp.MultiTenancy; @@ -41,6 +42,26 @@ public virtual Application Application public AddressType AddressType { get; set; } = AddressType.PhysicalAddress; public Guid? TenantId { get; set; } + /// + /// Indicates whether this address carries an explicit primary flag in its extra properties. + /// A missing flag is treated as "not primary". + /// + public bool IsFlaggedPrimary() + { + return this.HasProperty(AddressExtraPropertyNames.IsPrimary) + && this.GetProperty(AddressExtraPropertyNames.IsPrimary); + } + + /// + /// Sets the explicit primary flag on this address. + /// Primary is scoped to : an applicant may hold at most one + /// primary address within each address type group. + /// + public void SetPrimaryFlag(bool isPrimary) + { + this.SetProperty(AddressExtraPropertyNames.IsPrimary, isPrimary); + } + /// /// Returns a search-friendly address string (Street, Street2, City) for geocoding lookups. /// diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddressManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddressManager.cs new file mode 100644 index 0000000000..a2d797f391 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddressManager.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.GrantApplications; +using Volo.Abp.Domain.Services; + +namespace Unity.GrantManager.Applications; + +/// +/// Enforces the primary-address invariant: an applicant may hold at most one primary address +/// within each group. The rule is generic over the enum — no member +/// receives special treatment. +/// +public class ApplicantAddressManager(IApplicantAddressRepository applicantAddressRepository) + : DomainService, IApplicantAddressManager +{ + /// + public virtual async Task DemotePrimarySiblingsAsync( + Guid applicantId, + AddressType addressType, + Guid? excludeAddressId = null) + { + var siblings = await GetGroupAsync(applicantId, addressType, excludeAddressId); + + foreach (var sibling in siblings) + { + if (!sibling.IsFlaggedPrimary()) + { + continue; + } + + var trackedSibling = await GetTrackedAsync(sibling.Id); + trackedSibling.SetPrimaryFlag(false); + await applicantAddressRepository.UpdateAsync(trackedSibling); + } + } + + /// + public virtual async Task ElectPrimaryAsync( + Guid applicantId, + AddressType addressType, + Guid? excludeAddressId = null) + { + var candidates = await GetGroupAsync(applicantId, addressType, excludeAddressId); + + if (candidates.Count == 0 || candidates.Exists(candidate => candidate.IsFlaggedPrimary())) + { + return null; + } + + var mostRecent = candidates + .OrderByDescending(candidate => candidate.CreationTime) + .First(); + + var trackedAddress = await GetTrackedAsync(mostRecent.Id); + trackedAddress.SetPrimaryFlag(true); + await applicantAddressRepository.UpdateAsync(trackedAddress); + + return trackedAddress.Id; + } + + /// + /// Returns the applicant's addresses that belong to the given address type group, + /// optionally skipping one address. + /// + private async Task> GetGroupAsync( + Guid applicantId, + AddressType addressType, + Guid? excludeAddressId) + { + var addresses = await applicantAddressRepository.FindByApplicantIdAsync(applicantId); + + return + [ + .. addresses + .Where(address => address.AddressType == addressType) + .Where(address => !excludeAddressId.HasValue || address.Id != excludeAddressId.Value) + ]; + } + + /// + /// Re-reads an address through the repository. FindByApplicantIdAsync queries with + /// AsNoTracking, so the returned instances cannot be updated directly. + /// + private Task GetTrackedAsync(Guid addressId) + { + return applicantAddressRepository.GetAsync(addressId); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicantAddressManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicantAddressManager.cs new file mode 100644 index 0000000000..039b9532ed --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicantAddressManager.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; +using Unity.GrantManager.GrantApplications; + +namespace Unity.GrantManager.Applications; + +/// +/// Domain service that owns the "at most one primary address per group" +/// invariant for an applicant's addresses. Portal command handlers delegate here instead of +/// repeating the demote/elect loops. +/// +public interface IApplicantAddressManager +{ + /// + /// Clears the primary flag on every other address the applicant holds of the same + /// . Addresses of any other type are left untouched. + /// + /// Applicant owning the address group. + /// Address type group to demote within. + /// Address that is becoming primary, if it already exists. + Task DemotePrimarySiblingsAsync(Guid applicantId, AddressType addressType, Guid? excludeAddressId = null); + + /// + /// Promotes the most recently created address of to primary + /// when that group has no address flagged primary. Does nothing when the group is empty or + /// already has a primary. + /// + /// Applicant owning the address group. + /// Address type group to elect within. + /// Address to ignore, such as one being deleted or moved to another group. + /// The id of the address promoted to primary, or null when none was. + Task ElectPrimaryAsync(Guid applicantId, AddressType addressType, Guid? excludeAddressId = null); +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs index 708cd4e30a..4c4cc22141 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs @@ -19,6 +19,13 @@ namespace Unity.GrantManager.Applicants { public class AddressInfoDataProviderTests { + /// + /// Primary inference falls back to the most recently created address of a type, so only + /// the relative order of these matters — they are named for the role they play. + /// + private static readonly DateTime Older = new(2023, 1, 1, 10, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Newer = new(2023, 6, 15, 14, 30, 0, DateTimeKind.Utc); + private readonly ICurrentTenant _currentTenant; private readonly IRepository _submissionRepo; private readonly IRepository _addressRepo; @@ -86,6 +93,36 @@ private static ApplicantAddress CreateAddress(Action configure return entity; } + /// + /// Builds an application-scoped address from just the fields these tests vary: + /// address type, city (used as the identifying label in assertions), creation time + /// and the primary flag. Use the overload for anything else. + /// + private static ApplicantAddress CreateAddress( + Guid applicationId, + AddressType addressType = AddressType.PhysicalAddress, + string? city = null, + DateTime? creationTime = null, + bool isPrimary = false) + { + return CreateAddress(a => + { + a.ApplicationId = applicationId; + a.AddressType = addressType; + a.City = city; + + if (creationTime.HasValue) + { + a.CreationTime = creationTime.Value; + } + + if (isPrimary) + { + a.SetProperty(AddressExtraPropertyNames.IsPrimary, true); + } + }); + } + private static Application CreateApplication(Guid id, Action? configure = null) { var entity = new Application(); @@ -189,7 +226,7 @@ public async Task GetDataAsync_ShouldMapAddressTypeName(AddressType addressType, SetupQueryables( [CreateSubmission(applicationId, "TESTUSER")], - [CreateAddress(a => { a.ApplicationId = applicationId; a.AddressType = addressType; })], + [CreateAddress(applicationId, addressType)], [CreateApplication(applicationId)]); // Act @@ -210,8 +247,8 @@ public async Task GetDataAsync_ShouldReturnMultipleAddressesForSameSubmission() SetupQueryables( [CreateSubmission(applicationId, "TESTUSER")], [ - CreateAddress(a => { a.ApplicationId = applicationId; a.AddressType = AddressType.PhysicalAddress; a.City = "Victoria"; }), - CreateAddress(a => { a.ApplicationId = applicationId; a.AddressType = AddressType.MailingAddress; a.City = "Vancouver"; }) + CreateAddress(applicationId, AddressType.PhysicalAddress, "Victoria"), + CreateAddress(applicationId, AddressType.MailingAddress, "Vancouver") ], [CreateApplication(applicationId)]); @@ -232,7 +269,7 @@ public async Task GetDataAsync_ShouldNotReturnAddressesForOtherSubjects() SetupQueryables( [CreateSubmission(applicationId, "OTHERUSER")], - [CreateAddress(a => { a.ApplicationId = applicationId; a.City = "Victoria"; })], + [CreateAddress(applicationId, city: "Victoria")], [CreateApplication(applicationId)]); // Act @@ -319,7 +356,7 @@ public async Task GetDataAsync_ShouldCombineAddressesFromBothLinks() SetupQueryables( [CreateSubmission(applicationId, "TESTUSER", s => s.ApplicantId = applicantId)], [ - CreateAddress(a => { a.ApplicationId = applicationId; a.City = "Victoria"; }), + CreateAddress(applicationId, city: "Victoria"), CreateAddress(a => { a.ApplicantId = applicantId; a.City = "Kelowna"; }) ], [CreateApplication(applicationId, a => a.ReferenceNo = "REF-002")]); @@ -366,101 +403,85 @@ public async Task GetDataAsync_ShouldDeduplicateAddressesMatchingBothLinks() } [Fact] - public async Task GetDataAsync_ShouldMarkMostRecentAddressAsPrimaryWhenNoneMarked() + public async Task GetDataAsync_MultipleApplicantIds_ShouldMakeApplicantPathNotEditable() { // Arrange var request = CreateRequest(); - var applicationId = Guid.NewGuid(); - var oldAddress = CreateAddress(a => - { - a.ApplicationId = applicationId; - a.City = "Vancouver"; - a.CreationTime = new DateTime(2023, 1, 1, 10, 0, 0, DateTimeKind.Utc); - }); - var recentAddress = CreateAddress(a => - { - a.ApplicationId = applicationId; - a.City = "Victoria"; - a.CreationTime = new DateTime(2023, 6, 15, 14, 30, 0, DateTimeKind.Utc); - }); + var applicationId1 = Guid.NewGuid(); + var applicationId2 = Guid.NewGuid(); + var applicantId1 = Guid.NewGuid(); + var applicantId2 = Guid.NewGuid(); SetupQueryables( - [CreateSubmission(applicationId, "TESTUSER")], - [oldAddress, recentAddress], - [CreateApplication(applicationId)]); + [ + CreateSubmission(applicationId1, "TESTUSER", s => s.ApplicantId = applicantId1), + CreateSubmission(applicationId2, "TESTUSER", s => s.ApplicantId = applicantId2) + ], + [ + CreateAddress(a => { a.ApplicantId = applicantId1; a.City = "Victoria"; }), + CreateAddress(a => { a.ApplicantId = applicantId2; a.City = "Vancouver"; }) + ]); // Act var result = await _provider.GetDataAsync(request); - // Assert + // Assert — multiple distinct ApplicantIds means applicant-path addresses are NOT editable var dto = result.ShouldBeOfType(); dto.Addresses.Count.ShouldBe(2); - var primary = dto.Addresses.Single(a => a.IsPrimary); - primary.City.ShouldBe("Victoria"); + dto.Addresses.ShouldAllBe(a => !a.IsEditable); } [Fact] - public async Task GetDataAsync_ShouldNotOverridePrimaryWhenAlreadySet() + public async Task GetDataAsync_ShouldInferOnePrimaryPerAddressTypeWhenNoneMarked() { - // Arrange var request = CreateRequest(); var applicationId = Guid.NewGuid(); - var primaryAddress = CreateAddress(a => - { - a.ApplicationId = applicationId; - a.City = "Vancouver"; - a.CreationTime = new DateTime(2023, 1, 1, 10, 0, 0, DateTimeKind.Utc); - a.SetProperty("isPrimary", true); - }); - var recentAddress = CreateAddress(a => - { - a.ApplicationId = applicationId; - a.City = "Victoria"; - a.CreationTime = new DateTime(2023, 6, 15, 14, 30, 0, DateTimeKind.Utc); - }); + // No address is flagged, so each type group must infer its own most recent one. SetupQueryables( [CreateSubmission(applicationId, "TESTUSER")], - [primaryAddress, recentAddress], + [ + CreateAddress(applicationId, AddressType.PhysicalAddress, "Vancouver", Older), + CreateAddress(applicationId, AddressType.PhysicalAddress, "Victoria", Newer), + CreateAddress(applicationId, AddressType.MailingAddress, "Nanaimo", Older), + CreateAddress(applicationId, AddressType.MailingAddress, "Kelowna", Newer) + ], [CreateApplication(applicationId)]); - // Act var result = await _provider.GetDataAsync(request); - // Assert var dto = result.ShouldBeOfType(); - dto.Addresses.Count.ShouldBe(2); - var primary = dto.Addresses.Single(a => a.IsPrimary); - primary.City.ShouldBe("Vancouver"); + dto.Addresses.Count.ShouldBe(4); + dto.Addresses.Count(a => a.IsPrimary).ShouldBe(2); + dto.Addresses.Single(a => a.AddressType == "Physical" && a.IsPrimary).City.ShouldBe("Victoria"); + dto.Addresses.Single(a => a.AddressType == "Mailing" && a.IsPrimary).City.ShouldBe("Kelowna"); } [Fact] - public async Task GetDataAsync_MultipleApplicantIds_ShouldMakeApplicantPathNotEditable() + public async Task GetDataAsync_ShouldOnlyInferPrimaryForTypeGroupsWithoutOne() { - // Arrange var request = CreateRequest(); - var applicationId1 = Guid.NewGuid(); - var applicationId2 = Guid.NewGuid(); - var applicantId1 = Guid.NewGuid(); - var applicantId2 = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + // Vancouver is flagged but is the OLDER Physical address: asserting it below only + // proves anything because inference, if it ran for this group, would pick Victoria. + // Mailing has nothing flagged, so that group must still infer. SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], [ - CreateSubmission(applicationId1, "TESTUSER", s => s.ApplicantId = applicantId1), - CreateSubmission(applicationId2, "TESTUSER", s => s.ApplicantId = applicantId2) + CreateAddress(applicationId, AddressType.PhysicalAddress, "Vancouver", Older, isPrimary: true), + CreateAddress(applicationId, AddressType.PhysicalAddress, "Victoria", Newer), + CreateAddress(applicationId, AddressType.MailingAddress, "Kelowna", Older) ], - [ - CreateAddress(a => { a.ApplicantId = applicantId1; a.City = "Victoria"; }), - CreateAddress(a => { a.ApplicantId = applicantId2; a.City = "Vancouver"; }) - ]); + [CreateApplication(applicationId)]); - // Act var result = await _provider.GetDataAsync(request); - // Assert — multiple distinct ApplicantIds means applicant-path addresses are NOT editable var dto = result.ShouldBeOfType(); - dto.Addresses.Count.ShouldBe(2); - dto.Addresses.ShouldAllBe(a => !a.IsEditable); + dto.Addresses.Count.ShouldBe(3); + dto.Addresses.Count(a => a.IsPrimary).ShouldBe(2); + dto.Addresses.Single(a => a.AddressType == "Physical" && a.IsPrimary).City.ShouldBe("Vancouver"); + dto.Addresses.Single(a => a.AddressType == "Mailing" && a.IsPrimary).City.ShouldBe("Kelowna"); } [Fact] @@ -478,7 +499,7 @@ public async Task GetDataAsync_ShouldNormalizeSubjectWithoutAtSign() SetupQueryables( [CreateSubmission(applicationId, "TESTUSER")], - [CreateAddress(a => { a.ApplicationId = applicationId; a.City = "Victoria"; })], + [CreateAddress(applicationId, city: "Victoria")], [CreateApplication(applicationId)]); // Act diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressCreateHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressCreateHandlerTests.cs index c722608535..d9dc2e0163 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressCreateHandlerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressCreateHandlerTests.cs @@ -35,6 +35,7 @@ public AddressCreateHandlerTests() _handler = new AddressCreateHandler( _addressRepository, + new ApplicantAddressManager(_addressRepository), NullLogger.Instance); } @@ -220,13 +221,10 @@ public async Task HandleAsync_WhenAddressAlreadyExists_ShouldReturnIdempotentMes #region Address type mapping [Theory] + // The full mapping table (casing, every member, unrecognised and numeric values) is covered + // by AddressTypeMapperTests. These two only prove the handler routes the payload value + // through the mapper at all, and that an absent value still reaches the fallback. [InlineData("MAILING", AddressType.MailingAddress)] - [InlineData("mailing", AddressType.MailingAddress)] - [InlineData("PHYSICAL", AddressType.PhysicalAddress)] - [InlineData("physical", AddressType.PhysicalAddress)] - [InlineData("BUSINESS", AddressType.BusinessAddress)] - [InlineData("business", AddressType.BusinessAddress)] - [InlineData("UNKNOWN", AddressType.PhysicalAddress)] [InlineData(null, AddressType.PhysicalAddress)] public async Task HandleAsync_ShouldMapAddressTypeCorrectly(string? addressType, AddressType expected) { @@ -323,20 +321,32 @@ public async Task HandleAsync_WhenApplicantIdEmpty_ShouldThrow() #region Primary demotion [Fact] - public async Task HandleAsync_WhenIsPrimaryTrue_ShouldDemoteSiblingAddresses() + public async Task HandleAsync_WhenIsPrimaryTrue_ShouldFlagNewAddressAndDemoteOnlySameTypeSiblings() { // Arrange var addressId = Guid.NewGuid(); var siblingId = Guid.NewGuid(); + var otherTypeSiblingId = Guid.NewGuid(); var applicantId = Guid.NewGuid(); - var sibling = WithId(new ApplicantAddress { ApplicantId = applicantId }, siblingId); + // The default payload creates a MAILING address, so the sibling shares that type. + var sibling = WithId( + new ApplicantAddress { ApplicantId = applicantId, AddressType = AddressType.MailingAddress }, + siblingId); sibling.SetProperty(AddressExtraPropertyNames.IsPrimary, true); + // A primary of a different type must survive: exclusivity is scoped to the type group. + var otherTypeSibling = WithId( + new ApplicantAddress { ApplicantId = applicantId, AddressType = AddressType.BusinessAddress }, + otherTypeSiblingId); + otherTypeSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, true); + _addressRepository.GetAsync(siblingId, Arg.Any(), Arg.Any()) .Returns(sibling); + _addressRepository.GetAsync(otherTypeSiblingId, Arg.Any(), Arg.Any()) + .Returns(otherTypeSibling); _addressRepository.FindByApplicantIdAsync(applicantId) - .Returns(new List { sibling }); + .Returns([sibling, otherTypeSibling]); ApplicantAddress? savedAddress = null; _addressRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) @@ -351,8 +361,10 @@ public async Task HandleAsync_WhenIsPrimaryTrue_ShouldDemoteSiblingAddresses() // Act await _handler.HandleAsync(payload); - // Assert — sibling should have isPrimary cleared + // Assert — the same-type sibling is demoted, the other type keeps its own primary, + // and the newly created address is the one now flagged. sibling.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); + otherTypeSibling.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); savedAddress.ShouldNotBeNull(); savedAddress.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressDeleteHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressDeleteHandlerTests.cs index 63d2900b3f..731192c800 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressDeleteHandlerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressDeleteHandlerTests.cs @@ -2,11 +2,14 @@ using NSubstitute; using Shouldly; using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantsPortal.Handlers; using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Data; using Volo.Abp.Domain.Entities; using Xunit; @@ -14,6 +17,14 @@ namespace Unity.GrantManager.GrantsPortal; public class AddressDeleteHandlerTests { + /// + /// Re-election picks the most recently created remaining address of the deleted address's + /// type, so only the relative order of these matters — they are named for the role they play. + /// + private static readonly DateTime Older = new(2023, 1, 1, 10, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Newer = new(2023, 6, 15, 14, 30, 0, DateTimeKind.Utc); + private static readonly DateTime Newest = new(2024, 3, 3, 8, 0, 0, DateTimeKind.Utc); + private readonly IApplicantAddressRepository _addressRepository; private readonly AddressDeleteHandler _handler; @@ -27,6 +38,7 @@ public AddressDeleteHandlerTests() _handler = new AddressDeleteHandler( _addressRepository, + new ApplicantAddressManager(_addressRepository), NullLogger.Instance); } @@ -87,6 +99,92 @@ public async Task HandleAsync_WhenAddressDoesNotExist_ShouldNotThrow() #endregion + #region Primary re-election + + private ApplicantAddress StubAddress(Guid applicantId, AddressType addressType, bool isPrimary, DateTime? creationTime = null) + { + var id = Guid.NewGuid(); + var address = WithId(new ApplicantAddress { ApplicantId = applicantId, AddressType = addressType }, id); + + if (creationTime.HasValue) + { + address.CreationTime = creationTime.Value; + } + + if (isPrimary) + { + address.SetProperty(AddressExtraPropertyNames.IsPrimary, true); + } + + _addressRepository.FindAsync(id, Arg.Any(), Arg.Any()).Returns(address); + _addressRepository.GetAsync(id, Arg.Any(), Arg.Any()).Returns(address); + return address; + } + + [Fact] + public async Task HandleAsync_WhenDeletedAddressWasPrimary_ShouldElectMostRecentOfSameTypeOnly() + { + var applicantId = Guid.NewGuid(); + + var address = StubAddress(applicantId, AddressType.MailingAddress, isPrimary: true); + var olderMailing = StubAddress(applicantId, AddressType.MailingAddress, isPrimary: false, creationTime: Older); + var newerMailing = StubAddress(applicantId, AddressType.MailingAddress, isPrimary: false, creationTime: Newer); + + // Deliberately the most recent address of ALL: if re-election ever stopped scoping by + // type it would pick this one, so the assertion below only holds while scoping works. + var physical = StubAddress(applicantId, AddressType.PhysicalAddress, isPrimary: false, creationTime: Newest); + + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns([address, olderMailing, newerMailing, physical]); + + var payload = CreatePayload(addressId: address.Id); + + await _handler.HandleAsync(payload); + + await _addressRepository.Received(1).DeleteAsync(address, Arg.Any(), Arg.Any()); + newerMailing.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); + olderMailing.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); + physical.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_WhenDeletedAddressWasNotPrimary_ShouldNotElectAnotherPrimary() + { + var applicantId = Guid.NewGuid(); + + var address = StubAddress(applicantId, AddressType.MailingAddress, isPrimary: false); + var otherMailing = StubAddress(applicantId, AddressType.MailingAddress, isPrimary: false); + + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns([address, otherMailing]); + + var payload = CreatePayload(addressId: address.Id); + + await _handler.HandleAsync(payload); + + otherMailing.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); + await _addressRepository.DidNotReceive().FindByApplicantIdAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenDeletedPrimaryHasNoApplicantId_ShouldNotLookupSiblings() + { + var addressId = Guid.NewGuid(); + var address = WithId(new ApplicantAddress { ApplicantId = null }, addressId); + address.SetProperty(AddressExtraPropertyNames.IsPrimary, true); + + _addressRepository.FindAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + + var payload = CreatePayload(addressId: addressId); + + await _handler.HandleAsync(payload); + + await _addressRepository.DidNotReceive().FindByApplicantIdAsync(Arg.Any()); + } + + #endregion + #region Validation [Fact] diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs index 9214dd0ccf..38af1af0d6 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs @@ -18,6 +18,13 @@ namespace Unity.GrantManager.GrantsPortal; public class AddressEditHandlerTests { + /// + /// Election picks the most recently created remaining address of a type, so only the + /// relative order of these matters — they are named for the role they play. + /// + private static readonly DateTime Older = new(2023, 1, 1, 10, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Newer = new(2023, 6, 15, 14, 30, 0, DateTimeKind.Utc); + private readonly IApplicantAddressRepository _addressRepository; private readonly AddressEditHandler _handler; @@ -30,6 +37,7 @@ public AddressEditHandlerTests() _handler = new AddressEditHandler( _addressRepository, + new ApplicantAddressManager(_addressRepository), NullLogger.Instance); } @@ -132,13 +140,10 @@ public async Task HandleAsync_ShouldCallUpdateOnRepository() #region Address type mapping [Theory] + // The full mapping table (casing, every member, unrecognised and numeric values) is covered + // by AddressTypeMapperTests. These two only prove the handler routes the payload value + // through the mapper at all, and that an absent value still reaches the fallback. [InlineData("MAILING", AddressType.MailingAddress)] - [InlineData("mailing", AddressType.MailingAddress)] - [InlineData("PHYSICAL", AddressType.PhysicalAddress)] - [InlineData("physical", AddressType.PhysicalAddress)] - [InlineData("BUSINESS", AddressType.BusinessAddress)] - [InlineData("business", AddressType.BusinessAddress)] - [InlineData("UNKNOWN", AddressType.PhysicalAddress)] [InlineData(null, AddressType.PhysicalAddress)] public async Task HandleAsync_ShouldMapAddressTypeCorrectly(string? addressType, AddressType expected) { @@ -207,35 +212,6 @@ public async Task HandleAsync_WhenDataMissing_ShouldThrow() #region Primary tracking - [Fact] - public async Task HandleAsync_WhenIsPrimaryTrue_ShouldPromoteAndDemoteSiblings() - { - // Arrange - var addressId = Guid.NewGuid(); - var siblingId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); - - var address = WithId(new ApplicantAddress { ApplicantId = applicantId }, addressId); - var sibling = WithId(new ApplicantAddress { ApplicantId = applicantId }, siblingId); - sibling.SetProperty(AddressExtraPropertyNames.IsPrimary, true); - - _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) - .Returns(address); - _addressRepository.GetAsync(siblingId, Arg.Any(), Arg.Any()) - .Returns(sibling); - _addressRepository.FindByApplicantIdAsync(applicantId) - .Returns(new List { address, sibling }); - - var payload = CreatePayload(addressId: addressId); - - // Act - await _handler.HandleAsync(payload); - - // Assert - address.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); - sibling.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); - } - [Fact] public async Task HandleAsync_WhenIsPrimaryFalse_ShouldClearIsPrimary() { @@ -294,8 +270,13 @@ public async Task HandleAsync_ShouldSkipSiblingsWithoutIsPrimaryProperty() var applicantId = Guid.NewGuid(); var address = WithId(new ApplicantAddress { ApplicantId = applicantId }, addressId); - var sibling = WithId(new ApplicantAddress { ApplicantId = applicantId }, siblingWithoutProp); - // sibling does NOT have isPrimary property + + // The sibling must share the type the payload edits the address INTO (MAILING), otherwise + // it is excluded by type scoping and never reaches the isPrimary check under test here. + // It deliberately has no isPrimary property at all. + var sibling = WithId( + new ApplicantAddress { ApplicantId = applicantId, AddressType = AddressType.MailingAddress }, + siblingWithoutProp); _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) .Returns(address); @@ -312,4 +293,150 @@ public async Task HandleAsync_ShouldSkipSiblingsWithoutIsPrimaryProperty() } #endregion + + #region Primary scoped per address type + + private static JObject CreateAddressData(string addressType, bool isPrimary) => JObject.FromObject(new + { + street = "123 Main St", + city = "Victoria", + province = "BC", + postalCode = "V8W 1A1", + country = "Canada", + addressType, + isPrimary + }); + + private ApplicantAddress StubAddress(Guid applicantId, AddressType addressType, bool isPrimary, DateTime? creationTime = null) + { + var id = Guid.NewGuid(); + var address = WithId(new ApplicantAddress { ApplicantId = applicantId, AddressType = addressType }, id); + + if (creationTime.HasValue) + { + address.CreationTime = creationTime.Value; + } + + if (isPrimary) + { + address.SetProperty(AddressExtraPropertyNames.IsPrimary, true); + } + + _addressRepository.GetAsync(id, Arg.Any(), Arg.Any()).Returns(address); + return address; + } + + [Fact] + public async Task HandleAsync_WhenIsPrimaryTrue_ShouldPromoteAndDemoteOnlySameTypeSiblings() + { + var applicantId = Guid.NewGuid(); + + var address = StubAddress(applicantId, AddressType.MailingAddress, isPrimary: false); + var mailingSibling = StubAddress(applicantId, AddressType.MailingAddress, isPrimary: true); + var physicalSibling = StubAddress(applicantId, AddressType.PhysicalAddress, isPrimary: true); + + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns([address, mailingSibling, physicalSibling]); + + var payload = CreatePayload( + addressId: address.Id, + data: CreateAddressData("MAILING", isPrimary: true)); + + await _handler.HandleAsync(payload); + + // The edited address is promoted, its same-type sibling is demoted, and the primary + // of the other type keeps its flag because exclusivity is scoped to the type group. + address.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); + mailingSibling.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); + physicalSibling.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); + } + + [Fact] + public async Task HandleAsync_WhenPrimaryAddressChangesType_ShouldElectNewPrimaryInPreviousType() + { + var applicantId = Guid.NewGuid(); + + var address = StubAddress(applicantId, AddressType.PhysicalAddress, isPrimary: true); + var olderPhysical = StubAddress(applicantId, AddressType.PhysicalAddress, isPrimary: false, creationTime: Older); + var newerPhysical = StubAddress(applicantId, AddressType.PhysicalAddress, isPrimary: false, creationTime: Newer); + + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns([address, olderPhysical, newerPhysical]); + + var payload = CreatePayload( + addressId: address.Id, + data: CreateAddressData("MAILING", isPrimary: true)); + + await _handler.HandleAsync(payload); + + address.AddressType.ShouldBe(AddressType.MailingAddress); + address.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); + newerPhysical.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); + olderPhysical.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_WhenPrimaryAddressChangesType_ShouldDemoteExistingPrimaryInNewType() + { + var applicantId = Guid.NewGuid(); + + var address = StubAddress(applicantId, AddressType.PhysicalAddress, isPrimary: true); + var mailingPrimary = StubAddress(applicantId, AddressType.MailingAddress, isPrimary: true); + + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns([address, mailingPrimary]); + + var payload = CreatePayload( + addressId: address.Id, + data: CreateAddressData("MAILING", isPrimary: true)); + + await _handler.HandleAsync(payload); + + address.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); + mailingPrimary.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_WhenNonPrimaryAddressChangesType_ShouldNotElectInPreviousType() + { + var applicantId = Guid.NewGuid(); + + var address = StubAddress(applicantId, AddressType.PhysicalAddress, isPrimary: false); + var otherPhysical = StubAddress(applicantId, AddressType.PhysicalAddress, isPrimary: false); + + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns([address, otherPhysical]); + + var payload = CreatePayload( + addressId: address.Id, + data: CreateAddressData("BUSINESS", isPrimary: false)); + + await _handler.HandleAsync(payload); + + address.AddressType.ShouldBe(AddressType.BusinessAddress); + otherPhysical.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_WhenPrimaryAddressKeepsItsType_ShouldNotElectAnotherPrimaryInThatType() + { + var applicantId = Guid.NewGuid(); + + var address = StubAddress(applicantId, AddressType.MailingAddress, isPrimary: true); + var otherMailing = StubAddress(applicantId, AddressType.MailingAddress, isPrimary: false); + + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns([address, otherMailing]); + + var payload = CreatePayload( + addressId: address.Id, + data: CreateAddressData("MAILING", isPrimary: true)); + + await _handler.HandleAsync(payload); + + address.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); + otherMailing.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); + } + + #endregion } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs index 7ef41287f4..61429d2bbb 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantsPortal.Handlers; using Unity.GrantManager.GrantsPortal.Messages; using Volo.Abp.Data; @@ -28,6 +29,7 @@ public AddressSetPrimaryHandlerTests() _handler = new AddressSetPrimaryHandler( _addressRepository, + new ApplicantAddressManager(_addressRepository), NullLogger.Instance); } @@ -75,39 +77,10 @@ public async Task HandleAsync_ShouldSetPrimaryOnTargetAddress() // Assert result.ShouldBe("Address set as primary"); - address.GetProperty("isPrimary").ShouldBeTrue(); + address.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); await _addressRepository.Received(1).UpdateAsync(address, Arg.Any(), Arg.Any()); } - [Fact] - public async Task HandleAsync_ShouldClearPrimaryOnSiblingAddresses() - { - // Arrange - var addressId = Guid.NewGuid(); - var siblingId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); - - var address = WithId(new ApplicantAddress { ApplicantId = applicantId }, addressId); - - var sibling = WithId(new ApplicantAddress { ApplicantId = applicantId }, siblingId); - sibling.SetProperty("isPrimary", true); - - _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) - .Returns(address); - _addressRepository.GetAsync(siblingId, Arg.Any(), Arg.Any()) - .Returns(sibling); - _addressRepository.FindByApplicantIdAsync(applicantId) - .Returns(new List { address, sibling }); - - var payload = CreatePayload(addressId: addressId); - - // Act - await _handler.HandleAsync(payload); - - // Assert — sibling should have isPrimary cleared - sibling.GetProperty("isPrimary").ShouldBeFalse(); - } - [Fact] public async Task HandleAsync_WhenNoApplicantId_ShouldNotLookupSiblings() { @@ -184,7 +157,7 @@ public async Task HandleAsync_ShouldSkipSiblingsAlreadyNotPrimary() var address = WithId(new ApplicantAddress { ApplicantId = applicantId }, addressId); var sibling = WithId(new ApplicantAddress { ApplicantId = applicantId }, siblingId); - sibling.SetProperty("isPrimary", false); + sibling.SetProperty(AddressExtraPropertyNames.IsPrimary, false); _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) .Returns(address); @@ -202,6 +175,95 @@ public async Task HandleAsync_ShouldSkipSiblingsAlreadyNotPrimary() #endregion + #region Primary scoped per address type + + [Fact] + public async Task HandleAsync_ShouldDemoteOnlySiblingsOfTheSameAddressType() + { + var addressId = Guid.NewGuid(); + var sameTypeSiblingId = Guid.NewGuid(); + var otherTypeSiblingId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + + var address = WithId( + new ApplicantAddress { ApplicantId = applicantId, AddressType = AddressType.BusinessAddress }, + addressId); + + var sameTypeSibling = WithId( + new ApplicantAddress { ApplicantId = applicantId, AddressType = AddressType.BusinessAddress }, + sameTypeSiblingId); + sameTypeSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, true); + + var otherTypeSibling = WithId( + new ApplicantAddress { ApplicantId = applicantId, AddressType = AddressType.MailingAddress }, + otherTypeSiblingId); + otherTypeSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, true); + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + _addressRepository.GetAsync(sameTypeSiblingId, Arg.Any(), Arg.Any()) + .Returns(sameTypeSibling); + _addressRepository.GetAsync(otherTypeSiblingId, Arg.Any(), Arg.Any()) + .Returns(otherTypeSibling); + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns([address, sameTypeSibling, otherTypeSibling]); + + var payload = CreatePayload(addressId: addressId); + + await _handler.HandleAsync(payload); + + sameTypeSibling.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); + otherTypeSibling.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); + } + + [Theory] + [InlineData(AddressType.PhysicalAddress)] + [InlineData(AddressType.MailingAddress)] + [InlineData(AddressType.BusinessAddress)] + public async Task HandleAsync_ShouldAllowOnePrimaryPerAddressType(AddressType addressType) + { + var addressId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + + var address = WithId( + new ApplicantAddress { ApplicantId = applicantId, AddressType = addressType }, + addressId); + + var otherTypePrimaries = new List { address }; + + foreach (var otherType in Enum.GetValues()) + { + if (otherType == addressType) + { + continue; + } + + var otherTypeSiblingId = Guid.NewGuid(); + var otherTypeSibling = WithId( + new ApplicantAddress { ApplicantId = applicantId, AddressType = otherType }, + otherTypeSiblingId); + otherTypeSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, true); + + _addressRepository.GetAsync(otherTypeSiblingId, Arg.Any(), Arg.Any()) + .Returns(otherTypeSibling); + + otherTypePrimaries.Add(otherTypeSibling); + } + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns(otherTypePrimaries); + + var payload = CreatePayload(addressId: addressId); + + await _handler.HandleAsync(payload); + + otherTypePrimaries.ShouldAllBe(a => a.GetProperty(AddressExtraPropertyNames.IsPrimary)); + } + + #endregion + #region Validation [Fact] diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/GrantApplications/AddressTypeMapperTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/GrantApplications/AddressTypeMapperTests.cs new file mode 100644 index 0000000000..a001ee3aba --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/GrantApplications/AddressTypeMapperTests.cs @@ -0,0 +1,75 @@ +using System; +using Shouldly; +using Unity.GrantManager.GrantApplications; +using Xunit; + +namespace Unity.GrantManager.Domain.Tests.GrantApplications +{ + /// + /// is a pure static translation with no dependencies, + /// so these tests deliberately do not inherit the ABP domain test base. + /// + public class AddressTypeMapperTests + { + [Theory] + [InlineData("MAILING")] + [InlineData(" Mailing ")] + public void FromPortalValue_IgnoresCasingAndSurroundingWhitespace(string portalValue) + { + AddressTypeMapper.FromPortalValue(portalValue).ShouldBe(AddressType.MailingAddress); + } + + [Theory] + [InlineData(null)] + [InlineData(" ")] + public void FromPortalValue_MissingValue_ReturnsDefault(string? portalValue) + { + AddressTypeMapper.FromPortalValue(portalValue).ShouldBe(AddressTypeMapper.DefaultAddressType); + } + + /// + /// A non-empty but unrecognised value gets past the missing-value guard and matches no + /// member, so the mapper must still fall back to the default. Each case guards a + /// different way that could break: "PhysicalAddress" is the full member name rather + /// than the short name, "Mail" is a prefix of a valid name, and "0" would be accepted + /// by an Enum.TryParse-based rewrite and yield an undefined member. + /// + [Theory] + [InlineData("Residential")] + [InlineData("PhysicalAddress")] + [InlineData("Mail")] + [InlineData("0")] + public void FromPortalValue_UnrecognisedValue_ReturnsDefault(string portalValue) + { + AddressTypeMapper.FromPortalValue(portalValue).ShouldBe(AddressTypeMapper.DefaultAddressType); + } + + /// + /// Pins the exact short names exchanged with the applicant portal. The round-trip test + /// below deliberately does not assert literals, so this is what holds the wire contract. + /// + [Theory] + [InlineData(AddressType.PhysicalAddress, "Physical")] + [InlineData(AddressType.MailingAddress, "Mailing")] + [InlineData(AddressType.BusinessAddress, "Business")] + public void ToDisplayName_StripsTheAddressSuffix(AddressType addressType, string expected) + { + AddressTypeMapper.ToDisplayName(addressType).ShouldBe(expected); + } + + /// + /// Guards the round trip for every member, so a new AddressType cannot be added without + /// the portal short name resolving back to it. + /// + [Fact] + public void FromPortalValue_RoundTripsEveryDefinedMember() + { + foreach (var addressType in Enum.GetValues()) + { + var displayName = AddressTypeMapper.ToDisplayName(addressType); + + AddressTypeMapper.FromPortalValue(displayName).ShouldBe(addressType); + } + } + } +}