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.
713 lines
23 KiB
C#
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);
|