- ShiftService with 7 methods: list, detail, create, update, delete, signup, cancel
- 5 DTOs: ShiftListDto, ShiftDetailDto, CreateShiftRequest, UpdateShiftRequest, ShiftSignupDto
- Minimal API endpoints: GET /api/shifts, GET /api/shifts/{id}, POST, PUT, DELETE, POST /signup, DELETE /signup
- Capacity validation: sign-up rejected when full → 409 Conflict
- Past shift blocking: cannot sign up for past shifts → 422 Unprocessable
- Duplicate signup prevention: check existing before create → 409 Conflict
- Concurrency: 2-attempt retry loop for last-slot race conditions
- Authorization: POST/PUT (Manager+), DELETE (Admin), signup/cancel (Member+)
- Test infrastructure: Added X-Test-UserId header support for member ID injection
- 13 TDD integration tests: CRUD, sign-up, capacity, past shift, concurrency
- Build: 0 errors (6 BouncyCastle warnings expected)
Task 15 complete. Wave 3: 3/5 tasks done.
668 lines
22 KiB
C#
668 lines
22 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using WorkClub.Domain.Entities;
|
|
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);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
[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()
|
|
{
|
|
// Arrange
|
|
var clubId = Guid.NewGuid();
|
|
var createdBy = Guid.NewGuid();
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
using (var scope = Factory.Services.CreateScope())
|
|
{
|
|
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
// Shift in date range
|
|
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
|
|
});
|
|
|
|
// Shift outside date range
|
|
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 = now.AddDays(1).ToString("o");
|
|
var to = now.AddDays(5).ToString("o");
|
|
|
|
// Act
|
|
var response = await Client.GetAsync($"/api/shifts?from={from}&to={to}");
|
|
|
|
// Assert
|
|
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 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("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" });
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SignUpForShift_WhenFull_ReturnsConflict()
|
|
{
|
|
// 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 = 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 = Guid.NewGuid(),
|
|
SignedUpAt = now
|
|
});
|
|
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
SetTenant("tenant1");
|
|
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" });
|
|
|
|
// 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 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 = "Past Shift",
|
|
StartTime = now.AddHours(-2), // Past shift
|
|
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" });
|
|
|
|
// 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 shiftId = Guid.NewGuid();
|
|
var clubId = Guid.NewGuid();
|
|
var createdBy = Guid.NewGuid();
|
|
var memberId = Guid.Parse("00000000-0000-0000-0000-000000000001"); // Fixed member ID
|
|
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
|
|
});
|
|
|
|
// Add existing signup
|
|
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" }, memberId.ToString());
|
|
|
|
// 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 shiftId = Guid.NewGuid();
|
|
var clubId = Guid.NewGuid();
|
|
var createdBy = Guid.NewGuid();
|
|
var memberId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
|
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" }, memberId.ToString());
|
|
|
|
// Act
|
|
var response = await Client.DeleteAsync($"/api/shifts/{shiftId}/signup");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
// Verify signup was deleted
|
|
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 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 = 2,
|
|
ClubId = clubId,
|
|
CreatedById = createdBy,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
});
|
|
|
|
// Add one signup (leaving one slot)
|
|
context.ShiftSignups.Add(new ShiftSignup
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TenantId = "tenant1",
|
|
ShiftId = shiftId,
|
|
MemberId = Guid.NewGuid(),
|
|
SignedUpAt = now
|
|
});
|
|
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
SetTenant("tenant1");
|
|
|
|
// Act - Simulate two concurrent requests
|
|
var member1 = Guid.NewGuid();
|
|
var member2 = Guid.NewGuid();
|
|
|
|
AuthenticateAs("member1@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" }, member1.ToString());
|
|
var response1Task = Client.PostAsync($"/api/shifts/{shiftId}/signup", null);
|
|
|
|
AuthenticateAs("member2@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" }, member2.ToString());
|
|
var response2Task = Client.PostAsync($"/api/shifts/{shiftId}/signup", null);
|
|
|
|
var responses = await Task.WhenAll(response1Task, response2Task);
|
|
|
|
// Assert - One should succeed (200), one should fail (409)
|
|
var statuses = responses.Select(r => r.StatusCode).OrderBy(s => s).ToList();
|
|
Assert.Contains(HttpStatusCode.OK, statuses);
|
|
Assert.Contains(HttpStatusCode.Conflict, statuses);
|
|
|
|
// Verify only 2 total signups exist (capacity limit enforced)
|
|
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);
|