Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions db/migrations/20260616120000_add-capability-id-indexes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Indexes on the capability-id (and user-id) filter columns used by the bulk email-campaign
-- rendering path (one WHERE col = ANY(...) query per data source) and by existing single-capability
-- lookups. PostgreSQL does not auto-index foreign-key referencing columns, so without these each
-- query is a sequential scan whose cost grows with table size regardless of the number of ids.

CREATE INDEX IF NOT EXISTS "IX_Membership_CapabilityId" ON "Membership" ("CapabilityId");
CREATE INDEX IF NOT EXISTS "IX_Membership_UserId" ON "Membership" ("UserId");
CREATE INDEX IF NOT EXISTS "IX_AwsAccount_CapabilityId" ON "AwsAccount" ("CapabilityId");
CREATE INDEX IF NOT EXISTS "IX_AzureResource_CapabilityId" ON "AzureResource" ("CapabilityId");
CREATE INDEX IF NOT EXISTS "IX_MembershipApplication_CapabilityId_Status" ON "MembershipApplication" ("CapabilityId", "Status");
173 changes: 140 additions & 33 deletions src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using SelfService.Application;
using SelfService.Domain.Models;
using SelfService.Domain.Queries;
using SelfService.Domain.Services;
using SelfService.Infrastructure.Persistence;
using SelfService.Infrastructure.Persistence.Models;
using SelfService.Tests.Builders;

namespace SelfService.Tests.Application;

Expand Down Expand Up @@ -184,36 +189,29 @@ public async Task resolve_user_audience_filters_members_by_recipient_filter_role

var rbac = new Mock<IRbacApplicationService>();
rbac.Setup(x => x.GetAssignableRoles()).ReturnsAsync(new List<RbacRole> { ownerRole, readerRole });
rbac.Setup(x => x.GetRoleGrantsForUser(ownerMember.Id.ToString()))
.ReturnsAsync(
new List<RbacRoleGrant>
{
new(
RbacRoleGrantId.New(),
ownerRole.Id,
DateTime.UtcNow,
AssignedEntityType.User,
ownerMember.Id.ToString(),
RbacAccessType.Capability,
"some-capability"
),
}
);
rbac.Setup(x => x.GetRoleGrantsForUser(readerMember.Id.ToString()))
.ReturnsAsync(
new List<RbacRoleGrant>
{
new(
RbacRoleGrantId.New(),
readerRole.Id,
DateTime.UtcNow,
AssignedEntityType.User,
readerMember.Id.ToString(),
RbacAccessType.Capability,
"some-capability"
),
}
);
var roleGrants = new List<RbacRoleGrant>
{
new(
RbacRoleGrantId.New(),
ownerRole.Id,
DateTime.UtcNow,
AssignedEntityType.User,
ownerMember.Id.ToString(),
RbacAccessType.Capability,
"some-capability"
),
new(
RbacRoleGrantId.New(),
readerRole.Id,
DateTime.UtcNow,
AssignedEntityType.User,
readerMember.Id.ToString(),
RbacAccessType.Capability,
"some-capability"
),
};
rbac.Setup(x => x.GetRoleGrantsForUsers(It.IsAny<IReadOnlyCollection<string>>()))
.ReturnsAsync(roleGrants.ToLookup(g => g.AssignedEntityId));

var sut = BuildService(userFilter: userFilter.Object, rbac: rbac.Object);

Expand All @@ -224,13 +222,106 @@ public async Task resolve_user_audience_filters_members_by_recipient_filter_role
Assert.Equal("owner@dfds.com", result.Users[0].Email);
}

[Fact]
public async Task user_capabilities_loop_excludes_non_active_capabilities()
{
// Memberships linger on capabilities that are deleted / pending deletion; those must NOT
// appear in the {{#each User.Capabilities}} loop — only active capabilities.
var member = new Member(UserId.Parse("user@dfds.com"), "user@dfds.com", "User", UserSettings.Default);

var activeCap = A.Capability.WithId(CapabilityId.CreateFrom("active-cap")).WithName("Active Cap").Build();
var deletedCap = A
.Capability.WithId(CapabilityId.CreateFrom("deleted-cap"))
.WithName("Deleted Cap")
.WithStatus(CapabilityStatusOptions.Deleted)
.Build();

var campaign = EmailCampaign.CreateDraft(
name: "Campaign",
subject: "Subject",
contentJson: "{}",
contentHtml: "{{#each User.Capabilities}}[{{Capability.Name}}]{{/each}}",
audienceJson: "{\"mode\":\"all\"}",
recipientFilter: null,
createdBy: "tester"
);

var campaignRepo = new Mock<IEmailCampaignRepository>();
campaignRepo.Setup(x => x.FindById(It.IsAny<EmailCampaignId>())).ReturnsAsync(campaign);

var userFilter = new Mock<IUserFilterService>();
userFilter
.Setup(x => x.ResolveUsers(It.IsAny<string>()))
.ReturnsAsync(new UserAudienceResolution { Members = new List<Member> { member } });

var membershipRepo = new Mock<IMembershipRepository>();
membershipRepo
.Setup(x => x.GetAllMembershipsForUserIds(It.IsAny<IEnumerable<UserId>>()))
.ReturnsAsync(
new List<Membership>
{
A.Membership.WithUserId(member.Id).WithCapabilityId(activeCap.Id),
A.Membership.WithUserId(member.Id).WithCapabilityId(deletedCap.Id),
}
);
membershipRepo
.Setup(x => x.GetMemberCountsByCapabilityIds(It.IsAny<IEnumerable<CapabilityId>>()))
.ReturnsAsync(new Dictionary<CapabilityId, int>());

var capabilityRepo = new Mock<ICapabilityRepository>();
capabilityRepo
.Setup(x => x.GetByIds(It.IsAny<IEnumerable<CapabilityId>>()))
.ReturnsAsync(new List<Capability> { activeCap, deletedCap });

var awsRepo = new Mock<IAwsAccountRepository>();
awsRepo
.Setup(x => x.GetByCapabilityIds(It.IsAny<IEnumerable<CapabilityId>>()))
.ReturnsAsync(new List<AwsAccount>());
var azureRepo = new Mock<IAzureResourceRepository>();
azureRepo
.Setup(x => x.GetForCapabilityIds(It.IsAny<IEnumerable<CapabilityId>>()))
.ReturnsAsync(new List<AzureResource>());
var appQuery = new Mock<ICapabilityMembershipApplicationQuery>();
appQuery
.Setup(x => x.FindPendingByCapabilityIds(It.IsAny<IEnumerable<CapabilityId>>()))
.ReturnsAsync(new List<MembershipApplication>());
var metricService = new Mock<IRequirementsMetricService>();
metricService
.Setup(x => x.GetRequirementScoresForCapabilitiesAsync(It.IsAny<IReadOnlyCollection<string>>()))
.ReturnsAsync(new Dictionary<string, List<RequirementsMetric>>());

var scopeFactory = ScopeFactoryWith(
(typeof(IMembershipRepository), membershipRepo.Object),
(typeof(ICapabilityRepository), capabilityRepo.Object),
(typeof(IAwsAccountRepository), awsRepo.Object),
(typeof(IAzureResourceRepository), azureRepo.Object),
(typeof(ICapabilityMembershipApplicationQuery), appQuery.Object),
(typeof(IRequirementsMetricService), metricService.Object)
);

var sut = BuildService(
campaignRepo: campaignRepo.Object,
userFilter: userFilter.Object,
templateRendering: new TemplateRenderingService(),
scopeFactory: scopeFactory
);

var previews = await sut.PreviewUserCampaign(campaign.Id, new[] { member.Email });

Assert.Single(previews);
Assert.Contains("Active Cap", previews[0].Html);
Assert.DoesNotContain("Deleted Cap", previews[0].Html);
}

private static EmailCampaignApplicationService BuildService(
IEmailCampaignRepository? campaignRepo = null,
IEmailCampaignRecipientLogRepository? recipientLogRepo = null,
IEmailCampaignExecutionRepository? executionRepo = null,
ICapabilityFilterService? capabilityFilter = null,
IUserFilterService? userFilter = null,
IRbacApplicationService? rbac = null
IRbacApplicationService? rbac = null,
ITemplateRenderingService? templateRendering = null,
IServiceScopeFactory? scopeFactory = null
)
{
return new EmailCampaignApplicationService(
Expand All @@ -242,9 +333,25 @@ private static EmailCampaignApplicationService BuildService(
Mock.Of<IMemberRepository>(),
capabilityFilter ?? Mock.Of<ICapabilityFilterService>(),
userFilter ?? Mock.Of<IUserFilterService>(),
Mock.Of<ITemplateRenderingService>(),
templateRendering ?? Mock.Of<ITemplateRenderingService>(),
rbac ?? Mock.Of<IRbacApplicationService>(),
Mock.Of<IServiceScopeFactory>()
scopeFactory ?? Mock.Of<IServiceScopeFactory>()
);
}

// Wires an IServiceScopeFactory whose scope resolves the given service instances — mirrors the
// scoped resolution LoadUserCapabilitiesForMembers performs at runtime.
private static IServiceScopeFactory ScopeFactoryWith(params (Type type, object impl)[] services)
{
var provider = new Mock<IServiceProvider>();
foreach (var (type, impl) in services)
provider.Setup(p => p.GetService(type)).Returns(impl);

var scope = new Mock<IServiceScope>();
scope.SetupGet(s => s.ServiceProvider).Returns(provider.Object);

var factory = new Mock<IServiceScopeFactory>();
factory.Setup(f => f.CreateScope()).Returns(scope.Object);
return factory.Object;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -468,4 +468,9 @@ public Task<IEnumerable<MembershipApplication>> FindPendingBy(CapabilityId capab
{
return Task.FromResult(_result.AsEnumerable());
}

public Task<IEnumerable<MembershipApplication>> FindPendingByCapabilityIds(IEnumerable<CapabilityId> capabilityIds)
{
return Task.FromResult(_result.AsEnumerable());
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SelfService.Domain;
using SelfService.Domain.Models;
using SelfService.Infrastructure.BackgroundJobs;
using SelfService.Infrastructure.Persistence;
using SelfService.Tests.Comparers;
Expand Down Expand Up @@ -69,4 +70,66 @@ public async Task cancel_keeps_membership_for_capability_with_single_member()
var remaining = await dbContext.Memberships.ToListAsync();
Assert.Contains(member, remaining, new MembershipComparer());
}

[Fact]
[Trait("Category", "InMemoryDatabase")]
public async Task get_member_counts_by_capability_ids_returns_count_per_capability()
{
await using var databaseFactory = new InMemoryDatabaseFactory();
var dbContext = await databaseFactory.CreateSelfServiceDbContext();

var capA = A.Capability.Build();
var capB = A.Capability.Build();
var repo = A.MembershipRepository.WithDbContext(dbContext).Build();

await repo.Add(A.Membership.WithCapabilityId(capA.Id).WithUserId("U1").Build());
await repo.Add(A.Membership.WithCapabilityId(capA.Id).WithUserId("U2").Build());
await repo.Add(A.Membership.WithCapabilityId(capB.Id).WithUserId("U1").Build());
await dbContext.SaveChangesAsync();

var counts = await repo.GetMemberCountsByCapabilityIds(new[] { capA.Id, capB.Id });

Assert.Equal(2, counts[capA.Id]);
Assert.Equal(1, counts[capB.Id]);
}

[Fact]
[Trait("Category", "InMemoryDatabase")]
public async Task get_member_counts_by_capability_ids_empty_input_returns_empty()
{
await using var databaseFactory = new InMemoryDatabaseFactory();
var dbContext = await databaseFactory.CreateSelfServiceDbContext();
var repo = A.MembershipRepository.WithDbContext(dbContext).Build();

var counts = await repo.GetMemberCountsByCapabilityIds(Array.Empty<CapabilityId>());

Assert.Empty(counts);
}

[Fact]
[Trait("Category", "InMemoryDatabase")]
public async Task get_all_memberships_for_user_ids_returns_memberships_for_requested_users()
{
await using var databaseFactory = new InMemoryDatabaseFactory();
var dbContext = await databaseFactory.CreateSelfServiceDbContext();

var capA = A.Capability.Build();
var capB = A.Capability.Build();
var repo = A.MembershipRepository.WithDbContext(dbContext).Build();

var u1 = UserId.Parse("U1");
var u2 = UserId.Parse("U2");
var u3 = UserId.Parse("U3");
await repo.Add(A.Membership.WithCapabilityId(capA.Id).WithUserId("U1").Build());
await repo.Add(A.Membership.WithCapabilityId(capB.Id).WithUserId("U1").Build());
await repo.Add(A.Membership.WithCapabilityId(capA.Id).WithUserId("U2").Build());
await repo.Add(A.Membership.WithCapabilityId(capA.Id).WithUserId("U3").Build());
await dbContext.SaveChangesAsync();

var memberships = await repo.GetAllMembershipsForUserIds(new[] { u1, u2 });

Assert.Equal(3, memberships.Count);
Assert.All(memberships, m => Assert.Contains(m.UserId, new[] { u1, u2 }));
Assert.DoesNotContain(memberships, m => m.UserId == u3);
}
}
Loading
Loading