Files
work-club-manager/backend/WorkClub.Tests.Integration/Shifts/ShiftCrudTests.cs
WorkClub Automation a8730245b2
All checks were successful
CI Pipeline / Backend Build & Test (pull_request) Successful in 52s
CI Pipeline / Frontend Lint, Test & Build (pull_request) Successful in 29s
CI Pipeline / Infrastructure Validation (pull_request) Successful in 5s
fix(backend): resolve shift signup by looking up Member via ExternalUserId
The signup/cancel endpoints were passing the Keycloak sub claim (external UUID)
directly as MemberId, but ShiftSignup.MemberId references the internal Member.Id.
Now ShiftService resolves ExternalUserId to the internal Member.Id before creating
the signup record. Integration tests updated to seed proper Member entities.
2026-03-09 13:24:50 +01:00

713 lines
23 KiB
C#

using System.Net;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using WorkClub.Domain.Entities;
using WorkClub.Domain.Enums;
using WorkClub.Infrastructure.Data;
using WorkClub.Tests.Integration.Infrastructure;
using Xunit;
namespace WorkClub.Tests.Integration.Shifts;
public class ShiftCrudTests : IntegrationTestBase
{
public ShiftCrudTests(CustomWebApplicationFactory<Program> factory) : base(factory)
{
}
public override async Task InitializeAsync()
{
using var scope = Factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Clean up existing test data
context.ShiftSignups.RemoveRange(context.ShiftSignups);
context.Shifts.RemoveRange(context.Shifts);
context.Members.RemoveRange(context.Members);
context.Clubs.RemoveRange(context.Clubs);
await context.SaveChangesAsync();
}
private async Task<(Guid clubId, Guid memberId, string externalUserId)> SeedMemberAsync(
string tenantId,
string email,
string? externalUserId = null,
ClubRole role = ClubRole.Member)
{
externalUserId ??= Guid.NewGuid().ToString();
var clubId = Guid.NewGuid();
var memberId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using var scope = Factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var existingClub = await context.Clubs.FirstOrDefaultAsync(c => c.TenantId == tenantId);
if (existingClub != null)
{
clubId = existingClub.Id;
}
else
{
context.Clubs.Add(new Club
{
Id = clubId,
TenantId = tenantId,
Name = "Test Club",
SportType = SportType.Tennis,
CreatedAt = now,
UpdatedAt = now
});
}
context.Members.Add(new Member
{
Id = memberId,
TenantId = tenantId,
ExternalUserId = externalUserId,
DisplayName = email.Split('@')[0],
Email = email,
Role = role,
ClubId = clubId,
CreatedAt = now,
UpdatedAt = now
});
await context.SaveChangesAsync();
return (clubId, memberId, externalUserId);
}
[Fact]
public async Task CreateShift_AsManager_ReturnsCreated()
{
// Arrange
var clubId = Guid.NewGuid();
SetTenant("tenant1");
AuthenticateAs("manager@test.com", new Dictionary<string, string> { ["tenant1"] = "Manager" });
var request = new
{
Title = "Morning Shift",
Description = "Morning work shift",
Location = "Main Office",
StartTime = DateTimeOffset.UtcNow.AddDays(1),
EndTime = DateTimeOffset.UtcNow.AddDays(1).AddHours(4),
Capacity = 5,
ClubId = clubId
};
// Act
var response = await Client.PostAsJsonAsync("/api/shifts", request);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ShiftDetailResponse>();
Assert.NotNull(result);
Assert.Equal("Morning Shift", result.Title);
Assert.Equal(5, result.Capacity);
Assert.NotEqual(Guid.Empty, result.Id);
}
[Fact]
public async Task CreateShift_AsViewer_ReturnsForbidden()
{
// Arrange
var clubId = Guid.NewGuid();
SetTenant("tenant1");
AuthenticateAs("viewer@test.com", new Dictionary<string, string> { ["tenant1"] = "Viewer" });
var request = new
{
Title = "Morning Shift",
StartTime = DateTimeOffset.UtcNow.AddDays(1),
EndTime = DateTimeOffset.UtcNow.AddDays(1).AddHours(4),
ClubId = clubId
};
// Act
var response = await Client.PostAsJsonAsync("/api/shifts", request);
// Assert
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task ListShifts_WithDateFilter_ReturnsFilteredShifts()
{
var clubId = Guid.NewGuid();
var createdBy = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shifts.Add(new Shift
{
Id = Guid.NewGuid(),
TenantId = "tenant1",
Title = "Shift 1",
StartTime = now.AddDays(2),
EndTime = now.AddDays(2).AddHours(4),
Capacity = 5,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now,
UpdatedAt = now
});
context.Shifts.Add(new Shift
{
Id = Guid.NewGuid(),
TenantId = "tenant1",
Title = "Shift 2",
StartTime = now.AddDays(10),
EndTime = now.AddDays(10).AddHours(4),
Capacity = 5,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now,
UpdatedAt = now
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" });
var from = Uri.EscapeDataString(now.AddDays(1).ToString("o"));
var to = Uri.EscapeDataString(now.AddDays(5).ToString("o"));
var response = await Client.GetAsync($"/api/shifts?from={from}&to={to}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ShiftListResponse>();
Assert.NotNull(result);
Assert.Single(result.Items);
Assert.Equal("Shift 1", result.Items[0].Title);
}
[Fact]
public async Task GetShift_ById_ReturnsShiftWithSignupList()
{
// Arrange
var shiftId = Guid.NewGuid();
var clubId = Guid.NewGuid();
var createdBy = Guid.NewGuid();
var memberId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shifts.Add(new Shift
{
Id = shiftId,
TenantId = "tenant1",
Title = "Test Shift",
Description = "Test Description",
Location = "Test Location",
StartTime = now.AddDays(1),
EndTime = now.AddDays(1).AddHours(4),
Capacity = 5,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now,
UpdatedAt = now
});
context.ShiftSignups.Add(new ShiftSignup
{
Id = Guid.NewGuid(),
TenantId = "tenant1",
ShiftId = shiftId,
MemberId = memberId,
SignedUpAt = now
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" });
// Act
var response = await Client.GetAsync($"/api/shifts/{shiftId}");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ShiftDetailResponse>();
Assert.NotNull(result);
Assert.Equal(shiftId, result.Id);
Assert.Equal("Test Shift", result.Title);
Assert.Single(result.Signups);
Assert.Equal(memberId, result.Signups[0].MemberId);
}
[Fact]
public async Task UpdateShift_AsManager_UpdatesShift()
{
// Arrange
var shiftId = Guid.NewGuid();
var clubId = Guid.NewGuid();
var createdBy = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shifts.Add(new Shift
{
Id = shiftId,
TenantId = "tenant1",
Title = "Original Title",
StartTime = now.AddDays(1),
EndTime = now.AddDays(1).AddHours(4),
Capacity = 5,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now,
UpdatedAt = now
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
AuthenticateAs("manager@test.com", new Dictionary<string, string> { ["tenant1"] = "Manager" });
var updateRequest = new
{
Title = "Updated Title",
Capacity = 10
};
// Act
var response = await Client.PutAsync($"/api/shifts/{shiftId}", JsonContent.Create(updateRequest));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ShiftDetailResponse>();
Assert.NotNull(result);
Assert.Equal("Updated Title", result.Title);
Assert.Equal(10, result.Capacity);
}
[Fact]
public async Task DeleteShift_AsAdmin_DeletesShift()
{
// Arrange
var shiftId = Guid.NewGuid();
var clubId = Guid.NewGuid();
var createdBy = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shifts.Add(new Shift
{
Id = shiftId,
TenantId = "tenant1",
Title = "Test Shift",
StartTime = now.AddDays(1),
EndTime = now.AddDays(1).AddHours(4),
Capacity = 5,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now,
UpdatedAt = now
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
AuthenticateAs("admin@test.com", new Dictionary<string, string> { ["tenant1"] = "Admin" });
// Act
var response = await Client.DeleteAsync($"/api/shifts/{shiftId}");
// Assert
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
// Verify shift is deleted
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var shift = await context.Shifts.FindAsync(shiftId);
Assert.Null(shift);
}
}
[Fact]
public async Task DeleteShift_AsManager_ReturnsForbidden()
{
// Arrange
var shiftId = Guid.NewGuid();
var clubId = Guid.NewGuid();
var createdBy = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shifts.Add(new Shift
{
Id = shiftId,
TenantId = "tenant1",
Title = "Test Shift",
StartTime = now.AddDays(1),
EndTime = now.AddDays(1).AddHours(4),
Capacity = 5,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now,
UpdatedAt = now
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
AuthenticateAs("manager@test.com", new Dictionary<string, string> { ["tenant1"] = "Manager" });
// Act
var response = await Client.DeleteAsync($"/api/shifts/{shiftId}");
// Assert
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task SignUpForShift_WithCapacity_ReturnsOk()
{
// Arrange
var (clubId, memberId, externalUserId) = await SeedMemberAsync("tenant1", "member@test.com");
var shiftId = Guid.NewGuid();
var createdBy = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shifts.Add(new Shift
{
Id = shiftId,
TenantId = "tenant1",
Title = "Test Shift",
StartTime = now.AddDays(1),
EndTime = now.AddDays(1).AddHours(4),
Capacity = 5,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now,
UpdatedAt = now
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" }, externalUserId);
// Act
var response = await Client.PostAsync($"/api/shifts/{shiftId}/signup", null);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Verify signup was created
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var signups = await context.ShiftSignups.Where(ss => ss.ShiftId == shiftId).ToListAsync();
Assert.Single(signups);
Assert.Equal(memberId, signups[0].MemberId);
}
}
[Fact]
public async Task SignUpForShift_WhenFull_ReturnsConflict()
{
// Arrange
var (clubId, _, externalUserId) = await SeedMemberAsync("tenant1", "member@test.com");
var shiftId = Guid.NewGuid();
var createdBy = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
// Seed a different member to fill the single slot
var (_, fillerMemberId, _) = await SeedMemberAsync("tenant1", "filler@test.com");
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shifts.Add(new Shift
{
Id = shiftId,
TenantId = "tenant1",
Title = "Test Shift",
StartTime = now.AddDays(1),
EndTime = now.AddDays(1).AddHours(4),
Capacity = 1,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now,
UpdatedAt = now
});
// Add one signup to fill capacity
context.ShiftSignups.Add(new ShiftSignup
{
Id = Guid.NewGuid(),
TenantId = "tenant1",
ShiftId = shiftId,
MemberId = fillerMemberId,
SignedUpAt = now
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" }, externalUserId);
// Act
var response = await Client.PostAsync($"/api/shifts/{shiftId}/signup", null);
// Assert
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
[Fact]
public async Task SignUpForShift_ForPastShift_ReturnsUnprocessableEntity()
{
// Arrange
var (clubId, _, externalUserId) = await SeedMemberAsync("tenant1", "member@test.com");
var shiftId = Guid.NewGuid();
var createdBy = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shifts.Add(new Shift
{
Id = shiftId,
TenantId = "tenant1",
Title = "Past Shift",
StartTime = now.AddHours(-2),
EndTime = now.AddHours(-1),
Capacity = 5,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddDays(-1)
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" }, externalUserId);
// Act
var response = await Client.PostAsync($"/api/shifts/{shiftId}/signup", null);
// Assert
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task SignUpForShift_Duplicate_ReturnsConflict()
{
// Arrange
var (clubId, memberId, externalUserId) = await SeedMemberAsync("tenant1", "member@test.com");
var shiftId = Guid.NewGuid();
var createdBy = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shifts.Add(new Shift
{
Id = shiftId,
TenantId = "tenant1",
Title = "Test Shift",
StartTime = now.AddDays(1),
EndTime = now.AddDays(1).AddHours(4),
Capacity = 5,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now,
UpdatedAt = now
});
context.ShiftSignups.Add(new ShiftSignup
{
Id = Guid.NewGuid(),
TenantId = "tenant1",
ShiftId = shiftId,
MemberId = memberId,
SignedUpAt = now
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" }, externalUserId);
// Act
var response = await Client.PostAsync($"/api/shifts/{shiftId}/signup", null);
// Assert
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
[Fact]
public async Task CancelSignup_BeforeShift_ReturnsOk()
{
// Arrange
var (clubId, memberId, externalUserId) = await SeedMemberAsync("tenant1", "member@test.com");
var shiftId = Guid.NewGuid();
var createdBy = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shifts.Add(new Shift
{
Id = shiftId,
TenantId = "tenant1",
Title = "Test Shift",
StartTime = now.AddDays(1),
EndTime = now.AddDays(1).AddHours(4),
Capacity = 5,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now,
UpdatedAt = now
});
context.ShiftSignups.Add(new ShiftSignup
{
Id = Guid.NewGuid(),
TenantId = "tenant1",
ShiftId = shiftId,
MemberId = memberId,
SignedUpAt = now
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" }, externalUserId);
// Act
var response = await Client.DeleteAsync($"/api/shifts/{shiftId}/signup");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var signups = await context.ShiftSignups.Where(ss => ss.ShiftId == shiftId && ss.MemberId == memberId).ToListAsync();
Assert.Empty(signups);
}
}
[Fact]
public async Task SignUpForShift_ConcurrentLastSlot_HandlesRaceCondition()
{
// Arrange
var (clubId, fillerMemberId, _) = await SeedMemberAsync("tenant1", "filler@test.com");
var (_, _, externalUserId1) = await SeedMemberAsync("tenant1", "member1@test.com");
var (_, _, externalUserId2) = await SeedMemberAsync("tenant1", "member2@test.com");
var shiftId = Guid.NewGuid();
var createdBy = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shifts.Add(new Shift
{
Id = shiftId,
TenantId = "tenant1",
Title = "Test Shift",
StartTime = now.AddDays(1),
EndTime = now.AddDays(1).AddHours(4),
Capacity = 2,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now,
UpdatedAt = now
});
context.ShiftSignups.Add(new ShiftSignup
{
Id = Guid.NewGuid(),
TenantId = "tenant1",
ShiftId = shiftId,
MemberId = fillerMemberId,
SignedUpAt = now
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
// Act
AuthenticateAs("member1@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" }, externalUserId1);
var response1Task = Client.PostAsync($"/api/shifts/{shiftId}/signup", null);
AuthenticateAs("member2@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" }, externalUserId2);
var response2Task = Client.PostAsync($"/api/shifts/{shiftId}/signup", null);
var responses = await Task.WhenAll(response1Task, response2Task);
// Assert
var statuses = responses.Select(r => r.StatusCode).OrderBy(s => s).ToList();
Assert.Contains(HttpStatusCode.OK, statuses);
Assert.Contains(HttpStatusCode.Conflict, statuses);
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var signupCount = await context.ShiftSignups.CountAsync(ss => ss.ShiftId == shiftId);
Assert.Equal(2, signupCount);
}
}
}
// Response DTOs for test assertions
public record ShiftListResponse(List<ShiftListItemResponse> Items, int Total, int Page, int PageSize);
public record ShiftListItemResponse(Guid Id, string Title, DateTimeOffset StartTime, DateTimeOffset EndTime, int Capacity, int CurrentSignups);
public record ShiftDetailResponse(Guid Id, string Title, string? Description, string? Location, DateTimeOffset StartTime, DateTimeOffset EndTime, int Capacity, List<ShiftSignupResponse> Signups, Guid ClubId, Guid CreatedById, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
public record ShiftSignupResponse(Guid Id, Guid MemberId, DateTimeOffset SignedUpAt);