From 62d05fadbf21e22085b48e702a9a07bce88c4798 Mon Sep 17 00:00:00 2001 From: Peter Drier Date: Wed, 15 Apr 2026 04:15:16 +0200 Subject: [PATCH 1/2] Fix /Barrios/Register 500 on duplicate Barrio Leads membership (#498) SyncBarrioLeadsMembershipForUserAsync now short-circuits when the user already has an active team_members row on the Barrio Leads system team, avoiding an IX_team_members_active_unique violation during a second camp registration. Also adds a DbUpdateException catch to CampController.Register as a belt-and-suspenders so any future race surfaces as a friendly ModelState error rather than an unhandled 500. Covered by two new SystemTeamSyncJob tests on the idempotent path. Closes #498 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Jobs/SystemTeamSyncJob.cs | 19 ++ src/Humans.Web/Controllers/CampController.cs | 13 ++ .../SystemTeamSyncJobBarrioLeadsTests.cs | 172 ++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 tests/Humans.Application.Tests/Services/SystemTeamSyncJobBarrioLeadsTests.cs diff --git a/src/Humans.Infrastructure/Jobs/SystemTeamSyncJob.cs b/src/Humans.Infrastructure/Jobs/SystemTeamSyncJob.cs index e5c1b53c..70c63036 100644 --- a/src/Humans.Infrastructure/Jobs/SystemTeamSyncJob.cs +++ b/src/Humans.Infrastructure/Jobs/SystemTeamSyncJob.cs @@ -501,6 +501,25 @@ public async Task SyncBarrioLeadsMembershipForUserAsync(Guid userId, Cancellatio .AsNoTracking() .AnyAsync(l => l.UserId == userId && l.LeftAt == null, cancellationToken); + // Idempotency guard: if the user should be a member and already has an active + // team_members row, do nothing. This avoids unique-index violations + // (IX_team_members_active_unique) on the Barrio Leads team when the user is + // registering another camp and already has an active membership from a + // previous registration. Checks against the database directly rather than the + // filtered Include collection to be robust against any tracker staleness. + if (isLeadAnywhere) + { + var alreadyActive = await _dbContext.TeamMembers + .AsNoTracking() + .AnyAsync( + tm => tm.TeamId == team.Id && tm.UserId == userId && tm.LeftAt == null, + cancellationToken); + if (alreadyActive) + { + return; + } + } + var eligibleUserIds = isLeadAnywhere ? [userId] : new List(); await SyncTeamMembershipAsync(team, eligibleUserIds, cancellationToken, singleUserSync: userId); } diff --git a/src/Humans.Web/Controllers/CampController.cs b/src/Humans.Web/Controllers/CampController.cs index 06ce57fb..6e9925b7 100644 --- a/src/Humans.Web/Controllers/CampController.cs +++ b/src/Humans.Web/Controllers/CampController.cs @@ -7,6 +7,7 @@ using Humans.Domain.Helpers; using Humans.Domain.ValueObjects; using Humans.Web.Models; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; using NodaTime; @@ -320,6 +321,18 @@ public async Task Register(CampRegisterViewModel model) await PopulateRegistrationInfoAsync(); return View(model); } + catch (DbUpdateException ex) + { + // Belt-and-suspenders for any future race in downstream sync side effects + // (e.g. duplicate system-team memberships). The primary fix lives in the + // owning service; this ensures the user always sees a friendly error + // instead of a 500. + _logger.LogError(ex, "Camp registration failed with DB error for user {UserId} in year {Year}", user.Id, year); + ModelState.AddModelError(string.Empty, "We couldn't register your camp right now. Please try again, or contact an admin if the problem persists."); + await PopulateRegisterSeasonYearAsync(); + await PopulateRegistrationInfoAsync(); + return View(model); + } } // ====================================================================== diff --git a/tests/Humans.Application.Tests/Services/SystemTeamSyncJobBarrioLeadsTests.cs b/tests/Humans.Application.Tests/Services/SystemTeamSyncJobBarrioLeadsTests.cs new file mode 100644 index 00000000..ba06ac0d --- /dev/null +++ b/tests/Humans.Application.Tests/Services/SystemTeamSyncJobBarrioLeadsTests.cs @@ -0,0 +1,172 @@ +using AwesomeAssertions; +using Humans.Application.Interfaces; +using Humans.Domain.Entities; +using Humans.Domain.Enums; +using Humans.Infrastructure.Data; +using Humans.Infrastructure.Jobs; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using NodaTime; +using NodaTime.Testing; +using NSubstitute; +using Xunit; + +namespace Humans.Application.Tests.Services; + +/// +/// Regression tests for . +/// Covers #498: duplicate camp registration for a user who is already an active member of the +/// Barrio Leads system team must not violate IX_team_members_active_unique. +/// +public class SystemTeamSyncJobBarrioLeadsTests : IDisposable +{ + private readonly HumansDbContext _dbContext; + private readonly FakeClock _clock; + private readonly SystemTeamSyncJob _job; + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + + public SystemTeamSyncJobBarrioLeadsTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + _dbContext = new HumansDbContext(options); + _clock = new FakeClock(Instant.FromUtc(2026, 4, 15, 12, 0)); + + _job = new SystemTeamSyncJob( + _dbContext, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + _cache, + Substitute.For(), + NullLogger.Instance, + _clock); + } + + public void Dispose() + { + _cache.Dispose(); + _dbContext.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task SyncBarrioLeadsMembershipForUserAsync_UserAlreadyActiveMember_IsNoOp() + { + // Arrange: user is lead of one camp and already has an active team_members row + // for the Barrio Leads system team (as they would after registering a first camp). + var userId = Guid.NewGuid(); + var team = await SeedBarrioLeadsTeamAsync(); + var camp1 = await SeedCampAsync("camp-one"); + var camp2 = await SeedCampAsync("camp-two"); + + _dbContext.CampLeads.Add(new CampLead + { + Id = Guid.NewGuid(), + CampId = camp1.Id, + UserId = userId, + Role = CampLeadRole.CoLead, + JoinedAt = _clock.GetCurrentInstant() + }); + _dbContext.TeamMembers.Add(new TeamMember + { + Id = Guid.NewGuid(), + TeamId = team.Id, + UserId = userId, + Role = TeamMemberRole.Member, + JoinedAt = _clock.GetCurrentInstant() + }); + await _dbContext.SaveChangesAsync(); + + // Simulate a second camp registration: add another CampLead row, then call sync. + _dbContext.CampLeads.Add(new CampLead + { + Id = Guid.NewGuid(), + CampId = camp2.Id, + UserId = userId, + Role = CampLeadRole.CoLead, + JoinedAt = _clock.GetCurrentInstant() + }); + await _dbContext.SaveChangesAsync(); + + // Act + Assert: should NOT throw (previously failed with unique-index violation) + // and there should still be exactly one active membership after sync. + var act = async () => await _job.SyncBarrioLeadsMembershipForUserAsync(userId); + await act.Should().NotThrowAsync(); + + var activeRows = await _dbContext.TeamMembers + .Where(tm => tm.TeamId == team.Id && tm.UserId == userId && tm.LeftAt == null) + .CountAsync(); + activeRows.Should().Be(1); + } + + [Fact] + public async Task SyncBarrioLeadsMembershipForUserAsync_UserBecomesLead_AddsMembership() + { + // Arrange: team exists, user has a new CampLead but no TeamMember row yet. + var userId = Guid.NewGuid(); + var team = await SeedBarrioLeadsTeamAsync(); + var camp = await SeedCampAsync("new-camp"); + + _dbContext.CampLeads.Add(new CampLead + { + Id = Guid.NewGuid(), + CampId = camp.Id, + UserId = userId, + Role = CampLeadRole.CoLead, + JoinedAt = _clock.GetCurrentInstant() + }); + await _dbContext.SaveChangesAsync(); + + // Act + await _job.SyncBarrioLeadsMembershipForUserAsync(userId); + + // Assert: single active membership created. + var activeRows = await _dbContext.TeamMembers + .Where(tm => tm.TeamId == team.Id && tm.UserId == userId && tm.LeftAt == null) + .CountAsync(); + activeRows.Should().Be(1); + } + + // ------------------------------------------------------------------ + // Seed helpers + // ------------------------------------------------------------------ + + private async Task SeedBarrioLeadsTeamAsync() + { + var team = new Team + { + Id = Guid.NewGuid(), + Name = "Barrio Leads", + Slug = "barrio-leads", + Description = "System team for barrio leads", + SystemTeamType = SystemTeamType.BarrioLeads, + IsActive = true, + IsHidden = true, + CreatedAt = _clock.GetCurrentInstant(), + UpdatedAt = _clock.GetCurrentInstant() + }; + _dbContext.Teams.Add(team); + await _dbContext.SaveChangesAsync(); + return team; + } + + private async Task SeedCampAsync(string slug) + { + var camp = new Camp + { + Id = Guid.NewGuid(), + Slug = slug, + ContactEmail = $"{slug}@example.com", + ContactPhone = "+34600000000", + CreatedAt = _clock.GetCurrentInstant(), + UpdatedAt = _clock.GetCurrentInstant() + }; + _dbContext.Camps.Add(camp); + await _dbContext.SaveChangesAsync(); + return camp; + } +} From 388ffdd5ccdd4657e4a8cfedfcdda1f82e110854 Mon Sep 17 00:00:00 2001 From: Peter Drier Date: Wed, 15 Apr 2026 04:21:52 +0200 Subject: [PATCH 2/2] Revoke Google access when a team is soft-deleted (#494) TeamService.DeleteTeamAsync now closes out all active team_members rows (LeftAt = now) and delegates Google resource deactivation to ITeamResourceService.DeactivateResourcesForTeamAsync, a new method owned by TeamResourceService (the sole writer for google_resources). It flips IsActive on every active resource for the team and writes a GoogleResourceDeactivated audit log entry for each. Actual revocation of Drive permissions and Group membership happens on the next GoogleWorkspaceSyncService tick via the existing remove-user paths. Adds a belt-and-suspenders r.Team.IsActive filter to the bulk Google sync queries (SyncResourcesByType, Group settings drift, UpdateDriveFolderPaths, EnforceInheritedAccessRestrictions, and the per-team add-user path) so a future bug in the delete path cannot re-leak access to inactive teams. Covered by new unit tests: - TeamServiceTests.DeleteTeamAsync_* verify membership closure and that the owning service is called (no direct DB on google_resources). - TeamResourceServiceDeactivateTests verify IsActive flips and audit log entries are written for each previously-active resource. Closes #494 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Interfaces/ITeamResourceService.cs | 9 ++ .../Services/GoogleWorkspaceSyncService.cs | 13 +- .../Services/StubTeamResourceService.cs | 31 ++++ .../Services/TeamResourceService.cs | 32 ++++ .../Services/TeamService.cs | 25 ++- .../TeamResourceServiceDeactivateTests.cs | 145 ++++++++++++++++++ .../Services/TeamServiceTests.cs | 67 +++++++- 7 files changed, 311 insertions(+), 11 deletions(-) create mode 100644 tests/Humans.Application.Tests/Services/TeamResourceServiceDeactivateTests.cs diff --git a/src/Humans.Application/Interfaces/ITeamResourceService.cs b/src/Humans.Application/Interfaces/ITeamResourceService.cs index 255b13b3..0d87c021 100644 --- a/src/Humans.Application/Interfaces/ITeamResourceService.cs +++ b/src/Humans.Application/Interfaces/ITeamResourceService.cs @@ -110,6 +110,15 @@ Task> GetTeamResourceSummariesAsy /// Task UnlinkResourceAsync(Guid resourceId, CancellationToken ct = default); + /// + /// Deactivates every Google resource owned by a team (soft-delete: IsActive = false) + /// and writes an audit log entry for each. Called by when a + /// team is soft-deleted so the downstream sync jobs stop provisioning access to its + /// Drive folders and Groups. The actual revocation of Drive permissions and Group + /// membership happens on the next sync tick via the normal remove-user paths. + /// + Task DeactivateResourcesForTeamAsync(Guid teamId, CancellationToken ct = default); + /// /// Checks whether a user can manage resources for a team. /// Board members can always manage. Leads can manage if the admin setting allows it. diff --git a/src/Humans.Infrastructure/Services/GoogleWorkspaceSyncService.cs b/src/Humans.Infrastructure/Services/GoogleWorkspaceSyncService.cs index fc4f5ae3..a6ccfa8d 100644 --- a/src/Humans.Infrastructure/Services/GoogleWorkspaceSyncService.cs +++ b/src/Humans.Infrastructure/Services/GoogleWorkspaceSyncService.cs @@ -773,7 +773,7 @@ public async Task AddUserToTeamResourcesAsync( : null; var resources = await _dbContext.GoogleResources - .Where(r => r.TeamId == teamId && r.IsActive) + .Where(r => r.TeamId == teamId && r.IsActive && r.Team.IsActive) .ToListAsync(cancellationToken); foreach (var resource in resources) @@ -803,7 +803,7 @@ public async Task AddUserToTeamResourcesAsync( if (team?.ParentTeamId is not null) { var parentResources = await _dbContext.GoogleResources - .Where(r => r.TeamId == team.ParentTeamId && r.IsActive) + .Where(r => r.TeamId == team.ParentTeamId && r.IsActive && r.Team.IsActive) .ToListAsync(cancellationToken); foreach (var resource in parentResources) @@ -926,7 +926,7 @@ public async Task SyncResourcesByTypeAsync( .Include(r => r.Team) .ThenInclude(t => t.Members.Where(tm => tm.LeftAt == null)) .ThenInclude(tm => tm.User) - .Where(r => r.ResourceType == resourceType && r.IsActive) + .Where(r => r.ResourceType == resourceType && r.IsActive && r.Team.IsActive) .ToListAsync(cancellationToken); var now = _clock.GetCurrentInstant(); @@ -1843,7 +1843,7 @@ public async Task CheckGroupSettingsAsync(Cancellation var groupResources = await _dbContext.GoogleResources .Include(r => r.Team) - .Where(r => r.ResourceType == GoogleResourceType.Group && r.IsActive) + .Where(r => r.ResourceType == GoogleResourceType.Group && r.IsActive && r.Team.IsActive) .ToListAsync(cancellationToken); _logger.LogInformation("Checking group settings for {Count} active Google Groups", groupResources.Count); @@ -2297,7 +2297,7 @@ public async Task GetAllDomainGroupsAsync(CancellationToken can public async Task UpdateDriveFolderPathsAsync(CancellationToken cancellationToken = default) { var driveResources = await _dbContext.GoogleResources - .Where(r => r.ResourceType == GoogleResourceType.DriveFolder && r.IsActive) + .Where(r => r.ResourceType == GoogleResourceType.DriveFolder && r.IsActive && r.Team.IsActive) .ToListAsync(cancellationToken); if (driveResources.Count == 0) @@ -2410,7 +2410,8 @@ public async Task EnforceInheritedAccessRestrictionsAsync(CancellationToken var restrictedResources = await _dbContext.GoogleResources .Where(r => r.RestrictInheritedAccess && r.ResourceType == GoogleResourceType.DriveFolder - && r.IsActive) + && r.IsActive + && r.Team.IsActive) .ToListAsync(cancellationToken); if (restrictedResources.Count == 0) diff --git a/src/Humans.Infrastructure/Services/StubTeamResourceService.cs b/src/Humans.Infrastructure/Services/StubTeamResourceService.cs index 8f5fb40a..1d756daf 100644 --- a/src/Humans.Infrastructure/Services/StubTeamResourceService.cs +++ b/src/Humans.Infrastructure/Services/StubTeamResourceService.cs @@ -278,6 +278,37 @@ public async Task UnlinkResourceAsync(Guid resourceId, CancellationToken ct = de _logger.LogInformation("[STUB] Unlinked resource {ResourceId}", resourceId); } + /// + public async Task DeactivateResourcesForTeamAsync(Guid teamId, CancellationToken ct = default) + { + var resources = await _dbContext.GoogleResources + .Where(r => r.TeamId == teamId && r.IsActive) + .ToListAsync(ct); + + if (resources.Count == 0) + { + return; + } + + var auditLogService = _serviceProvider.GetRequiredService(); + + foreach (var resource in resources) + { + resource.IsActive = false; + await auditLogService.LogAsync( + AuditAction.GoogleResourceDeactivated, + nameof(GoogleResource), + resource.Id, + $"Resource '{resource.Name}' deactivated because owning team was soft-deleted.", + nameof(StubTeamResourceService)); + } + + await _dbContext.SaveChangesAsync(ct); + _logger.LogInformation( + "[STUB] Deactivated {Count} Google resources for soft-deleted team {TeamId}", + resources.Count, teamId); + } + /// public async Task CanManageTeamResourcesAsync(Guid teamId, Guid userId, CancellationToken ct = default) { diff --git a/src/Humans.Infrastructure/Services/TeamResourceService.cs b/src/Humans.Infrastructure/Services/TeamResourceService.cs index 99fac4ea..b2fcf651 100644 --- a/src/Humans.Infrastructure/Services/TeamResourceService.cs +++ b/src/Humans.Infrastructure/Services/TeamResourceService.cs @@ -505,6 +505,38 @@ public async Task UnlinkResourceAsync(Guid resourceId, CancellationToken ct = de _logger.LogInformation("Unlinked resource {ResourceId} ({ResourceName})", resourceId, resource.Name); } + /// + public async Task DeactivateResourcesForTeamAsync(Guid teamId, CancellationToken ct = default) + { + var resources = await _dbContext.GoogleResources + .Where(r => r.TeamId == teamId && r.IsActive) + .ToListAsync(ct); + + if (resources.Count == 0) + { + return; + } + + var auditLogService = _serviceProvider.GetRequiredService(); + + foreach (var resource in resources) + { + resource.IsActive = false; + await auditLogService.LogAsync( + AuditAction.GoogleResourceDeactivated, + nameof(GoogleResource), + resource.Id, + $"Resource '{resource.Name}' deactivated because owning team was soft-deleted.", + nameof(TeamResourceService)); + } + + await _dbContext.SaveChangesAsync(ct); + + _logger.LogInformation( + "Deactivated {Count} Google resources for soft-deleted team {TeamId}", + resources.Count, teamId); + } + /// public async Task CanManageTeamResourcesAsync(Guid teamId, Guid userId, CancellationToken ct = default) { diff --git a/src/Humans.Infrastructure/Services/TeamService.cs b/src/Humans.Infrastructure/Services/TeamService.cs index 8e2e3bab..38674f5d 100644 --- a/src/Humans.Infrastructure/Services/TeamService.cs +++ b/src/Humans.Infrastructure/Services/TeamService.cs @@ -640,14 +640,35 @@ public async Task DeleteTeamAsync(Guid teamId, CancellationToken cancellationTok throw new InvalidOperationException("Cannot deactivate a team that has active sub-teams. Remove or reassign sub-teams first."); } + var now = _clock.GetCurrentInstant(); + + // Close out all active team memberships so downstream sync jobs stop treating + // this team's roster as current and can revoke Drive/Group access on their + // next tick. (See #494.) + var activeMembers = await _dbContext.TeamMembers + .Where(tm => tm.TeamId == teamId && tm.LeftAt == null) + .ToListAsync(cancellationToken); + foreach (var member in activeMembers) + { + member.LeftAt = now; + } + team.IsActive = false; - team.UpdatedAt = _clock.GetCurrentInstant(); + team.UpdatedAt = now; await _dbContext.SaveChangesAsync(cancellationToken); + // Deactivate the team's Google resources via the owning service so the bulk + // sync queries stop hitting them. Actual revocation of user permissions and + // group memberships happens on the next sync tick through the normal + // remove-user paths. + await TeamResourceService.DeactivateResourcesForTeamAsync(teamId, cancellationToken); + RemoveCachedTeam(teamId); - _logger.LogInformation("Deactivated team {TeamId} ({TeamName})", teamId, team.Name); + _logger.LogInformation( + "Deactivated team {TeamId} ({TeamName}); closed {MemberCount} memberships", + teamId, team.Name, activeMembers.Count); } public async Task RequestToJoinTeamAsync( diff --git a/tests/Humans.Application.Tests/Services/TeamResourceServiceDeactivateTests.cs b/tests/Humans.Application.Tests/Services/TeamResourceServiceDeactivateTests.cs new file mode 100644 index 00000000..4198bf4c --- /dev/null +++ b/tests/Humans.Application.Tests/Services/TeamResourceServiceDeactivateTests.cs @@ -0,0 +1,145 @@ +using AwesomeAssertions; +using Humans.Application.Interfaces; +using Humans.Domain.Entities; +using Humans.Domain.Enums; +using Humans.Infrastructure.Configuration; +using Humans.Infrastructure.Data; +using Humans.Infrastructure.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NodaTime; +using NodaTime.Testing; +using NSubstitute; +using Xunit; + +namespace Humans.Application.Tests.Services; + +/// +/// Coverage for — the +/// side of #494 that actually flips IsActive and writes audit entries. Uses +/// so the test doesn't need real Google credentials; +/// the relevant method body (deactivation + audit) is logically identical to +/// . +/// +public class TeamResourceServiceDeactivateTests : IDisposable +{ + private readonly HumansDbContext _dbContext; + private readonly FakeClock _clock; + private readonly IAuditLogService _auditLogService; + private readonly StubTeamResourceService _service; + + public TeamResourceServiceDeactivateTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + _dbContext = new HumansDbContext(options); + _clock = new FakeClock(Instant.FromUtc(2026, 4, 15, 12, 0)); + _auditLogService = Substitute.For(); + + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuditLogService)).Returns(_auditLogService); + serviceProvider.GetService(typeof(ITeamService)).Returns(Substitute.For()); + + _service = new StubTeamResourceService( + _dbContext, + Options.Create(new TeamResourceManagementSettings()), + serviceProvider, + Substitute.For(), + _clock, + NullLogger.Instance); + } + + public void Dispose() + { + _dbContext.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task DeactivateResourcesForTeamAsync_FlipsIsActiveAndLogsAudit() + { + var teamId = Guid.NewGuid(); + var otherTeamId = Guid.NewGuid(); + SeedTeam(teamId, "Doomed"); + SeedTeam(otherTeamId, "Safe"); + + SeedResource(teamId, "Doomed Drive", GoogleResourceType.DriveFolder); + SeedResource(teamId, "Doomed Group", GoogleResourceType.Group); + SeedResource(otherTeamId, "Safe Drive", GoogleResourceType.DriveFolder); + // Already-inactive row on target team should not generate a duplicate audit. + SeedResource(teamId, "Already inactive", GoogleResourceType.DriveFolder, isActive: false); + await _dbContext.SaveChangesAsync(); + + await _service.DeactivateResourcesForTeamAsync(teamId); + + var doomedRows = await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => r.TeamId == teamId) + .ToListAsync(); + doomedRows.Should().OnlyContain(r => !r.IsActive); + + var safeRow = await _dbContext.GoogleResources + .AsNoTracking() + .SingleAsync(r => r.TeamId == otherTeamId); + safeRow.IsActive.Should().BeTrue(); + + // Exactly two audit entries (for the two previously-active doomed resources). + await _auditLogService.Received(2).LogAsync( + AuditAction.GoogleResourceDeactivated, + nameof(GoogleResource), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task DeactivateResourcesForTeamAsync_NoActiveResources_IsNoOp() + { + var teamId = Guid.NewGuid(); + SeedTeam(teamId, "Empty"); + await _dbContext.SaveChangesAsync(); + + await _service.DeactivateResourcesForTeamAsync(teamId); + + await _auditLogService.DidNotReceive().LogAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + private void SeedTeam(Guid id, string name) + { + _dbContext.Teams.Add(new Team + { + Id = id, + Name = name, + Slug = name.ToLowerInvariant(), + IsActive = true, + CreatedAt = _clock.GetCurrentInstant(), + UpdatedAt = _clock.GetCurrentInstant() + }); + } + + private void SeedResource(Guid teamId, string name, GoogleResourceType type, bool isActive = true) + { + _dbContext.GoogleResources.Add(new GoogleResource + { + Id = Guid.NewGuid(), + TeamId = teamId, + Name = name, + GoogleId = Guid.NewGuid().ToString(), + Url = $"https://example.com/{name}", + ResourceType = type, + IsActive = isActive, + ProvisionedAt = _clock.GetCurrentInstant() + }); + } +} diff --git a/tests/Humans.Application.Tests/Services/TeamServiceTests.cs b/tests/Humans.Application.Tests/Services/TeamServiceTests.cs index 7b01c64c..77645b2d 100644 --- a/tests/Humans.Application.Tests/Services/TeamServiceTests.cs +++ b/tests/Humans.Application.Tests/Services/TeamServiceTests.cs @@ -24,6 +24,7 @@ public class TeamServiceTests : IDisposable private readonly FakeClock _clock; private readonly TeamService _service; private readonly RoleAssignmentService _roleAssignmentService; + private readonly ITeamResourceService _teamResourceService; private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); public TeamServiceTests() @@ -44,11 +45,11 @@ public TeamServiceTests() NullLogger.Instance); var serviceProvider = Substitute.For(); serviceProvider.GetService(typeof(ITeamService)).Returns(Substitute.For()); - var teamResourceService = Substitute.For(); - teamResourceService + _teamResourceService = Substitute.For(); + _teamResourceService .GetTeamResourceSummariesAsync(Arg.Any>(), Arg.Any()) .Returns(new Dictionary()); - serviceProvider.GetService(typeof(ITeamResourceService)).Returns(teamResourceService); + serviceProvider.GetService(typeof(ITeamResourceService)).Returns(_teamResourceService); var shiftManagementService = new ShiftManagementService( _dbContext, Substitute.For(), @@ -1534,6 +1535,66 @@ public async Task GetAdminTeamListAsync_ReturnsZeroPendingShifts_WhenNoActiveEve summary.PendingShiftSignupCount.Should().Be(0); } + // ========================================================================== + // DeleteTeamAsync — #494: revoke Google access when a team is soft-deleted + // ========================================================================== + + [Fact] + public async Task DeleteTeamAsync_ClosesActiveMembershipsAndDeactivatesResources() + { + // Arrange + var team = SeedTeam("Doomed Team"); + var alice = SeedUser(displayName: "Alice"); + var bob = SeedUser(displayName: "Bob"); + SeedTeamMember(team.Id, alice.Id); + SeedTeamMember(team.Id, bob.Id); + // Previously-left member should NOT be touched. + var ghost = SeedUser(displayName: "Ghost"); + SeedTeamMember(team.Id, ghost.Id, leftAt: _clock.GetCurrentInstant() - Duration.FromDays(30)); + await _dbContext.SaveChangesAsync(); + + // Act + await _service.DeleteTeamAsync(team.Id); + + // Assert — team soft-deleted + var reloaded = await _dbContext.Teams.FindAsync(team.Id); + reloaded!.IsActive.Should().BeFalse(); + + // All previously-active memberships now have LeftAt set. + var aliceMember = await _dbContext.TeamMembers + .FirstAsync(tm => tm.TeamId == team.Id && tm.UserId == alice.Id); + var bobMember = await _dbContext.TeamMembers + .FirstAsync(tm => tm.TeamId == team.Id && tm.UserId == bob.Id); + aliceMember.LeftAt.Should().NotBeNull(); + bobMember.LeftAt.Should().NotBeNull(); + aliceMember.LeftAt.Should().Be(_clock.GetCurrentInstant()); + bobMember.LeftAt.Should().Be(_clock.GetCurrentInstant()); + + // Previously-left membership is unchanged (still has its original LeftAt). + var ghostMember = await _dbContext.TeamMembers + .FirstAsync(tm => tm.TeamId == team.Id && tm.UserId == ghost.Id); + ghostMember.LeftAt.Should().Be(_clock.GetCurrentInstant() - Duration.FromDays(30)); + + // Google resources deactivated via the owning service (no direct DB touch). + await _teamResourceService.Received(1).DeactivateResourcesForTeamAsync( + team.Id, Arg.Any()); + } + + [Fact] + public async Task DeleteTeamAsync_NoActiveMembers_StillDeactivatesResources() + { + var team = SeedTeam("Empty Team"); + await _dbContext.SaveChangesAsync(); + + await _service.DeleteTeamAsync(team.Id); + + var reloaded = await _dbContext.Teams.FindAsync(team.Id); + reloaded!.IsActive.Should().BeFalse(); + + await _teamResourceService.Received(1).DeactivateResourcesForTeamAsync( + team.Id, Arg.Any()); + } + // --- Helpers --- private User SeedUser(Guid? id = null, string displayName = "Test User")