Skip to content
Open
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
19 changes: 19 additions & 0 deletions db/migrations/20260616100000_add-missing-member-record-table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- 2026-06-16 10:00:00 : add-missing-member-record-table

CREATE TABLE IF NOT EXISTS "MissingMemberRecords"
(
"Id" uuid NOT NULL,
"UserId" varchar(255) NOT NULL,
"Status" varchar(50) NOT NULL, -- NotFound, Deactivated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be overkill, but I like the additional data for why we remove people

"FirstSeenMissingAt" timestamp NOT NULL,
"LastCheckedAt" timestamp NOT NULL,
"CreatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"UpdatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreatedAt and UpdatedAt are technically not necessary, but I like them to keep things aligned


CONSTRAINT "MissingMemberRecords_PK" PRIMARY KEY ("Id"),
CONSTRAINT "MissingMemberRecords_UserId_FK" FOREIGN KEY ("UserId") REFERENCES "Member" ("Id") ON DELETE CASCADE,
UNIQUE ("UserId")
);

CREATE INDEX "MissingMemberRecords_FirstSeenMissingAt_idx" ON "MissingMemberRecords" ("FirstSeenMissingAt" ASC);
CREATE INDEX "MissingMemberRecords_LastCheckedAt_idx" ON "MissingMemberRecords" ("LastCheckedAt" ASC);
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SelfService.Application;
using SelfService.Domain;
using SelfService.Domain.Models;
Expand All @@ -14,13 +15,38 @@ public class DeactivatedMemberCleanerApplicationServiceBuilder
private IMembershipRepository _membershipRepository;
private IMemberRepository _memberRepository;
private IMembershipApplicationRepository _membershipApplicationRepository;
private IMissingMemberRepository _missingMemberRepository;
private IRbacPermissionGrantRepository _rbacPermissionGrantRepository;
private IRbacRoleGrantRepository _rbacRoleGrantRepository;
private IRbacGroupMemberRepository _rbacGroupMemberRepository;
private ILogger<DeactivatedMemberCleanerApplicationService> _logger; //make correct logger

public DeactivatedMemberCleanerApplicationServiceBuilder()
{
_membershipRepository = Dummy.Of<IMembershipRepository>();
_memberRepository = Dummy.Of<IMemberRepository>();
_membershipApplicationRepository = Dummy.Of<IMembershipApplicationRepository>();
_missingMemberRepository = Dummy.Of<IMissingMemberRepository>();

// Configure RBAC repositories to return empty lists instead of null
var permissionGrantMock = new Mock<IRbacPermissionGrantRepository>();
permissionGrantMock
.Setup(x => x.GetAllWithPredicate(It.IsAny<Func<RbacPermissionGrant, bool>>()))
.ReturnsAsync(new List<RbacPermissionGrant>());
_rbacPermissionGrantRepository = permissionGrantMock.Object;

var roleGrantMock = new Mock<IRbacRoleGrantRepository>();
roleGrantMock
.Setup(x => x.GetByAssignedUsers(It.IsAny<IReadOnlyCollection<string>>()))
.ReturnsAsync(new List<RbacRoleGrant>());
_rbacRoleGrantRepository = roleGrantMock.Object;

var groupMemberMock = new Mock<IRbacGroupMemberRepository>();
groupMemberMock
.Setup(x => x.GetAllWithPredicate(It.IsAny<Func<RbacGroupMember, bool>>()))
.ReturnsAsync(new List<RbacGroupMember>());
_rbacGroupMemberRepository = groupMemberMock.Object;

_logger = Dummy.Of<ILogger<DeactivatedMemberCleanerApplicationService>>();
}

Expand All @@ -46,13 +72,49 @@ IMembershipApplicationRepository membershipApplicationRepository
return this;
}

public DeactivatedMemberCleanerApplicationServiceBuilder WithMissingMemberRepository(
IMissingMemberRepository missingMemberRepository
)
{
_missingMemberRepository = missingMemberRepository;
return this;
}

public DeactivatedMemberCleanerApplicationServiceBuilder WithRbacPermissionGrantRepository(
IRbacPermissionGrantRepository rbacPermissionGrantRepository
)
{
_rbacPermissionGrantRepository = rbacPermissionGrantRepository;
return this;
}

public DeactivatedMemberCleanerApplicationServiceBuilder WithRbacRoleGrantRepository(
IRbacRoleGrantRepository rbacRoleGrantRepository
)
{
_rbacRoleGrantRepository = rbacRoleGrantRepository;
return this;
}

public DeactivatedMemberCleanerApplicationServiceBuilder WithRbacGroupMemberRepository(
IRbacGroupMemberRepository rbacGroupMemberRepository
)
{
_rbacGroupMemberRepository = rbacGroupMemberRepository;
return this;
}

public DeactivatedMemberCleanerApplicationService Build()
{
return new DeactivatedMemberCleanerApplicationService(
logger: _logger,
membershipRepository: _membershipRepository,
memberRepository: _memberRepository,
membershipApplicationRepository: _membershipApplicationRepository
membershipApplicationRepository: _membershipApplicationRepository,
missingMemberRepository: _missingMemberRepository,
rbacPermissionGrantRepository: _rbacPermissionGrantRepository,
rbacRoleGrantRepository: _rbacRoleGrantRepository,
rbacGroupMemberRepository: _rbacGroupMemberRepository
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,80 @@ namespace SelfService.Tests.Infrastructure.Persistence;

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

var memberRepo = new MemberRepository(dbContext);
var membershipRepo = new MembershipRepository(dbContext);
var missingMemberRepo = new MissingMemberRepository(dbContext);
var membershipApplicationRepo = new MembershipApplicationRepository(dbContext, SystemTime.Default);

var member = A.Member.WithUserId("userdeactivated@dfds.com").Build();
await memberRepo.Add(member);
await dbContext.SaveChangesAsync();

var membershipCleaner = A
.DeactivatedMemberCleanerApplicationService.WithMemberRepository(memberRepo)
.WithMembershipRepository(membershipRepo)
.WithMembershipApplicationRepository(membershipApplicationRepo)
.WithMissingMemberRepository(missingMemberRepo)
.Build();

var userStatusChecker = new StubUserStatusChecker().WithDeactivatedUser(member.Id);

await membershipCleaner.RemoveDeactivatedMemberships(userStatusChecker);
await dbContext.SaveChangesAsync();

var memberAfterRun = await memberRepo.FindBy(member.Id);
Assert.NotNull(memberAfterRun);

var missingRecord = await missingMemberRepo.FindByUser(member.Id.ToString());
Assert.NotNull(missingRecord);
Assert.Equal(MissingMemberStatus.Deactivated, missingRecord.Status);
}

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

var memberRepo = new MemberRepository(dbContext);
var membershipRepo = new MembershipRepository(dbContext);
var missingMemberRepo = new MissingMemberRepository(dbContext);
var membershipApplicationRepo = new MembershipApplicationRepository(dbContext, SystemTime.Default);

var member = A.Member.WithUserId("userdeactivated@dfds.com").Build();
await memberRepo.Add(member);
await missingMemberRepo.Add(
new MissingMemberRecord(member.Id.ToString(), MissingMemberStatus.Deactivated, DateTime.UtcNow.AddDays(-8))
);
await dbContext.SaveChangesAsync();

var membershipCleaner = A
.DeactivatedMemberCleanerApplicationService.WithMemberRepository(memberRepo)
.WithMembershipRepository(membershipRepo)
.WithMembershipApplicationRepository(membershipApplicationRepo)
.WithMissingMemberRepository(missingMemberRepo)
.Build();

var userStatusChecker = new StubUserStatusChecker().WithDeactivatedUser(member.Id);

await membershipCleaner.RemoveDeactivatedMemberships(userStatusChecker);
await dbContext.SaveChangesAsync();

var memberAfterRun = await memberRepo.FindBy(member.Id);
Assert.Null(memberAfterRun);

var missingRecordAfterRun = await missingMemberRepo.FindByUser(member.Id.ToString());
Assert.Null(missingRecordAfterRun);
}

[Fact]
[Trait("Category", "InMemoryDatabase")]
public async Task deactivated_member_cleaner_removes_deactivated_users()
Expand All @@ -24,6 +98,7 @@ public async Task deactivated_member_cleaner_removes_deactivated_users()
.DeactivatedMemberCleanerApplicationService.WithMemberRepository(new MemberRepository(dbContext))
.WithMembershipRepository(new MembershipRepository(dbContext))
.WithMembershipApplicationRepository(new MembershipApplicationRepository(dbContext, systemTime))
.WithMissingMemberRepository(new MissingMemberRepository(dbContext))
.Build();

var capability = A.Capability.Build();
Expand Down Expand Up @@ -127,10 +202,22 @@ public async Task deactivated_member_cleaner_respects_referential_integrity()

//now the repository is in the right state

var missingMemberRepo = new MissingMemberRepository(dbContext);
// Pre-seed an expired missing record so the grace period is already elapsed
await missingMemberRepo.Add(
new MissingMemberRecord(
deactivatedMember.Id.ToString(),
MissingMemberStatus.Deactivated,
firstSeenAt: DateTime.UtcNow.AddDays(-8)
)
);
await dbContext.SaveChangesAsync();

var membershipCleaner = A
.DeactivatedMemberCleanerApplicationService.WithMemberRepository(memberRepo)
.WithMembershipRepository(membershipRepo)
.WithMembershipApplicationRepository(membershipApplicationRepo)
.WithMissingMemberRepository(missingMemberRepo)
.Build();

var userStatusChecker = new StubUserStatusChecker()
Expand All @@ -144,6 +231,8 @@ public async Task deactivated_member_cleaner_respects_referential_integrity()

// CLEAN UP
await memberRepo.Remove(approverMember.Id);
await dbContext.SaveChangesAsync();

dbContext.Capabilities.Remove(capability);
await dbContext.SaveChangesAsync();
}
Expand Down
Loading
Loading