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 factory) : base(factory) { } public override async Task InitializeAsync() { using var scope = Factory.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); // 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(); 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 { ["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(); 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 { ["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(); 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 { ["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(); 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(); 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 { ["tenant1"] = "Member" }); // Act var response = await Client.GetAsync($"/api/shifts/{shiftId}"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(); 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(); 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 { ["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(); 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(); 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 { ["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(); 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(); 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 { ["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(); 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 { ["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(); 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(); 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 { ["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(); 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 { ["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(); 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 { ["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(); 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 { ["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(); 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(); 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 { ["tenant1"] = "Member" }, externalUserId1); var response1Task = Client.PostAsync($"/api/shifts/{shiftId}/signup", null); AuthenticateAs("member2@test.com", new Dictionary { ["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(); var signupCount = await context.ShiftSignups.CountAsync(ss => ss.ShiftId == shiftId); Assert.Equal(2, signupCount); } } } // Response DTOs for test assertions public record ShiftListResponse(List 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 Signups, Guid ClubId, Guid CreatedById, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); public record ShiftSignupResponse(Guid Id, Guid MemberId, DateTimeOffset SignedUpAt);