feat(shifts): add Shift CRUD API with sign-up/cancel and capacity management

- 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.
This commit is contained in:
WorkClub Automation
2026-03-03 19:30:23 +01:00
parent 8dfe32dc95
commit 0ef1d0bbd4
12 changed files with 1461 additions and 2 deletions

View File

@@ -0,0 +1,167 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using WorkClub.Api.Services;
using WorkClub.Application.Shifts.DTOs;
namespace WorkClub.Api.Endpoints.Shifts;
public static class ShiftEndpoints
{
public static void MapShiftEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/shifts");
group.MapGet("", GetShifts)
.RequireAuthorization("RequireMember")
.WithName("GetShifts");
group.MapGet("{id:guid}", GetShift)
.RequireAuthorization("RequireMember")
.WithName("GetShift");
group.MapPost("", CreateShift)
.RequireAuthorization("RequireManager")
.WithName("CreateShift");
group.MapPut("{id:guid}", UpdateShift)
.RequireAuthorization("RequireManager")
.WithName("UpdateShift");
group.MapDelete("{id:guid}", DeleteShift)
.RequireAuthorization("RequireAdmin")
.WithName("DeleteShift");
group.MapPost("{id:guid}/signup", SignUpForShift)
.RequireAuthorization("RequireMember")
.WithName("SignUpForShift");
group.MapDelete("{id:guid}/signup", CancelSignup)
.RequireAuthorization("RequireMember")
.WithName("CancelSignup");
}
private static async Task<Ok<ShiftListDto>> GetShifts(
ShiftService shiftService,
[FromQuery] DateTimeOffset? from = null,
[FromQuery] DateTimeOffset? to = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await shiftService.GetShiftsAsync(from, to, page, pageSize);
return TypedResults.Ok(result);
}
private static async Task<Results<Ok<ShiftDetailDto>, NotFound>> GetShift(
Guid id,
ShiftService shiftService)
{
var result = await shiftService.GetShiftByIdAsync(id);
if (result == null)
return TypedResults.NotFound();
return TypedResults.Ok(result);
}
private static async Task<Results<Created<ShiftDetailDto>, BadRequest<string>>> CreateShift(
CreateShiftRequest request,
ShiftService shiftService,
HttpContext httpContext)
{
var userIdClaim = httpContext.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var createdById))
{
return TypedResults.BadRequest("Invalid user ID");
}
var (shift, error) = await shiftService.CreateShiftAsync(request, createdById);
if (error != null || shift == null)
return TypedResults.BadRequest(error ?? "Failed to create shift");
return TypedResults.Created($"/api/shifts/{shift.Id}", shift);
}
private static async Task<Results<Ok<ShiftDetailDto>, NotFound, Conflict<string>>> UpdateShift(
Guid id,
UpdateShiftRequest request,
ShiftService shiftService)
{
var (shift, error, isConflict) = await shiftService.UpdateShiftAsync(id, request);
if (error != null)
{
if (error == "Shift not found")
return TypedResults.NotFound();
if (isConflict)
return TypedResults.Conflict(error);
}
return TypedResults.Ok(shift!);
}
private static async Task<Results<NoContent, NotFound>> DeleteShift(
Guid id,
ShiftService shiftService)
{
var deleted = await shiftService.DeleteShiftAsync(id);
if (!deleted)
return TypedResults.NotFound();
return TypedResults.NoContent();
}
private static async Task<Results<Ok, NotFound, UnprocessableEntity<string>, Conflict<string>>> SignUpForShift(
Guid id,
ShiftService shiftService,
HttpContext httpContext)
{
var userIdClaim = httpContext.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var memberId))
{
return TypedResults.UnprocessableEntity("Invalid user ID");
}
var (success, error, isConflict) = await shiftService.SignUpForShiftAsync(id, memberId);
if (!success)
{
if (error == "Shift not found")
return TypedResults.NotFound();
if (error == "Cannot sign up for past shifts")
return TypedResults.UnprocessableEntity(error);
if (isConflict)
return TypedResults.Conflict(error!);
}
return TypedResults.Ok();
}
private static async Task<Results<Ok, NotFound, UnprocessableEntity<string>>> CancelSignup(
Guid id,
ShiftService shiftService,
HttpContext httpContext)
{
var userIdClaim = httpContext.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var memberId))
{
return TypedResults.UnprocessableEntity("Invalid user ID");
}
var (success, error) = await shiftService.CancelSignupAsync(id, memberId);
if (!success)
{
if (error == "Sign-up not found")
return TypedResults.NotFound();
return TypedResults.UnprocessableEntity(error!);
}
return TypedResults.Ok();
}
}

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using WorkClub.Api.Auth;
using WorkClub.Api.Endpoints.Shifts;
using WorkClub.Api.Endpoints.Tasks;
using WorkClub.Api.Middleware;
using WorkClub.Api.Services;
@@ -28,6 +29,7 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, TenantProvider>();
builder.Services.AddScoped<SeedDataService>();
builder.Services.AddScoped<TaskService>();
builder.Services.AddScoped<ShiftService>();
builder.Services.AddSingleton<TenantDbConnectionInterceptor>();
builder.Services.AddSingleton<SaveChangesTenantInterceptor>();
@@ -121,6 +123,7 @@ app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" }))
.RequireAuthorization();
app.MapTaskEndpoints();
app.MapShiftEndpoints();
app.Run();

View File

@@ -0,0 +1,283 @@
using Microsoft.EntityFrameworkCore;
using WorkClub.Application.Interfaces;
using WorkClub.Application.Shifts.DTOs;
using WorkClub.Domain.Entities;
using WorkClub.Infrastructure.Data;
namespace WorkClub.Api.Services;
public class ShiftService
{
private readonly AppDbContext _context;
private readonly ITenantProvider _tenantProvider;
public ShiftService(AppDbContext context, ITenantProvider tenantProvider)
{
_context = context;
_tenantProvider = tenantProvider;
}
public async Task<ShiftListDto> GetShiftsAsync(DateTimeOffset? from, DateTimeOffset? to, int page, int pageSize)
{
var query = _context.Shifts.AsQueryable();
if (from.HasValue)
query = query.Where(s => s.StartTime >= from.Value);
if (to.HasValue)
query = query.Where(s => s.StartTime <= to.Value);
var total = await query.CountAsync();
var shifts = await query
.OrderBy(s => s.StartTime)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var shiftIds = shifts.Select(s => s.Id).ToList();
var signupCounts = await _context.ShiftSignups
.Where(ss => shiftIds.Contains(ss.ShiftId))
.GroupBy(ss => ss.ShiftId)
.Select(g => new { ShiftId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ShiftId, x => x.Count);
var items = shifts.Select(s => new ShiftListItemDto(
s.Id,
s.Title,
s.StartTime,
s.EndTime,
s.Capacity,
signupCounts.GetValueOrDefault(s.Id, 0)
)).ToList();
return new ShiftListDto(items, total, page, pageSize);
}
public async Task<ShiftDetailDto?> GetShiftByIdAsync(Guid id)
{
var shift = await _context.Shifts.FindAsync(id);
if (shift == null)
return null;
var signups = await _context.ShiftSignups
.Where(ss => ss.ShiftId == id)
.OrderBy(ss => ss.SignedUpAt)
.ToListAsync();
var signupDtos = signups.Select(ss => new ShiftSignupDto(
ss.Id,
ss.MemberId,
ss.SignedUpAt
)).ToList();
return new ShiftDetailDto(
shift.Id,
shift.Title,
shift.Description,
shift.Location,
shift.StartTime,
shift.EndTime,
shift.Capacity,
signupDtos,
shift.ClubId,
shift.CreatedById,
shift.CreatedAt,
shift.UpdatedAt
);
}
public async Task<(ShiftDetailDto? shift, string? error)> CreateShiftAsync(CreateShiftRequest request, Guid createdById)
{
var tenantId = _tenantProvider.GetTenantId();
var shift = new Shift
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Title = request.Title,
Description = request.Description,
Location = request.Location,
StartTime = request.StartTime,
EndTime = request.EndTime,
Capacity = request.Capacity,
ClubId = request.ClubId,
CreatedById = createdById,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
_context.Shifts.Add(shift);
await _context.SaveChangesAsync();
var dto = new ShiftDetailDto(
shift.Id,
shift.Title,
shift.Description,
shift.Location,
shift.StartTime,
shift.EndTime,
shift.Capacity,
new List<ShiftSignupDto>(),
shift.ClubId,
shift.CreatedById,
shift.CreatedAt,
shift.UpdatedAt
);
return (dto, null);
}
public async Task<(ShiftDetailDto? shift, string? error, bool isConflict)> UpdateShiftAsync(Guid id, UpdateShiftRequest request)
{
var shift = await _context.Shifts.FindAsync(id);
if (shift == null)
return (null, "Shift not found", false);
if (request.Title != null)
shift.Title = request.Title;
if (request.Description != null)
shift.Description = request.Description;
if (request.Location != null)
shift.Location = request.Location;
if (request.StartTime.HasValue)
shift.StartTime = request.StartTime.Value;
if (request.EndTime.HasValue)
shift.EndTime = request.EndTime.Value;
if (request.Capacity.HasValue)
shift.Capacity = request.Capacity.Value;
shift.UpdatedAt = DateTimeOffset.UtcNow;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
return (null, "Shift was modified by another user. Please refresh and try again.", true);
}
var signups = await _context.ShiftSignups
.Where(ss => ss.ShiftId == id)
.OrderBy(ss => ss.SignedUpAt)
.ToListAsync();
var signupDtos = signups.Select(ss => new ShiftSignupDto(
ss.Id,
ss.MemberId,
ss.SignedUpAt
)).ToList();
var dto = new ShiftDetailDto(
shift.Id,
shift.Title,
shift.Description,
shift.Location,
shift.StartTime,
shift.EndTime,
shift.Capacity,
signupDtos,
shift.ClubId,
shift.CreatedById,
shift.CreatedAt,
shift.UpdatedAt
);
return (dto, null, false);
}
public async Task<bool> DeleteShiftAsync(Guid id)
{
var shift = await _context.Shifts.FindAsync(id);
if (shift == null)
return false;
_context.Shifts.Remove(shift);
await _context.SaveChangesAsync();
return true;
}
public async Task<(bool success, string? error, bool isConflict)> SignUpForShiftAsync(Guid shiftId, Guid memberId)
{
var tenantId = _tenantProvider.GetTenantId();
var shift = await _context.Shifts.FindAsync(shiftId);
if (shift == null)
return (false, "Shift not found", false);
if (shift.StartTime <= DateTimeOffset.UtcNow)
{
return (false, "Cannot sign up for past shifts", false);
}
var existingSignup = await _context.ShiftSignups
.FirstOrDefaultAsync(ss => ss.ShiftId == shiftId && ss.MemberId == memberId);
if (existingSignup != null)
{
return (false, "Already signed up for this shift", true);
}
for (int attempt = 0; attempt < 2; attempt++)
{
try
{
var currentSignups = await _context.ShiftSignups
.Where(ss => ss.ShiftId == shiftId)
.CountAsync();
if (currentSignups >= shift.Capacity)
{
return (false, "Shift is at full capacity", true);
}
var signup = new ShiftSignup
{
Id = Guid.NewGuid(),
TenantId = tenantId,
ShiftId = shiftId,
MemberId = memberId,
SignedUpAt = DateTimeOffset.UtcNow
};
_context.ShiftSignups.Add(signup);
await _context.SaveChangesAsync();
return (true, null, false);
}
catch (DbUpdateConcurrencyException) when (attempt == 0)
{
_context.Entry(shift).Reload();
}
}
return (false, "Shift capacity changed during sign-up", true);
}
public async Task<(bool success, string? error)> CancelSignupAsync(Guid shiftId, Guid memberId)
{
var signup = await _context.ShiftSignups
.FirstOrDefaultAsync(ss => ss.ShiftId == shiftId && ss.MemberId == memberId);
if (signup == null)
{
return (false, "Sign-up not found");
}
_context.ShiftSignups.Remove(signup);
await _context.SaveChangesAsync();
return (true, null);
}
}

View File

@@ -0,0 +1,11 @@
namespace WorkClub.Application.Shifts.DTOs;
public record CreateShiftRequest(
string Title,
string? Description,
string? Location,
DateTimeOffset StartTime,
DateTimeOffset EndTime,
int Capacity,
Guid ClubId
);

View File

@@ -0,0 +1,22 @@
namespace WorkClub.Application.Shifts.DTOs;
public record ShiftDetailDto(
Guid Id,
string Title,
string? Description,
string? Location,
DateTimeOffset StartTime,
DateTimeOffset EndTime,
int Capacity,
List<ShiftSignupDto> Signups,
Guid ClubId,
Guid CreatedById,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
);
public record ShiftSignupDto(
Guid Id,
Guid MemberId,
DateTimeOffset SignedUpAt
);

View File

@@ -0,0 +1,17 @@
namespace WorkClub.Application.Shifts.DTOs;
public record ShiftListDto(
List<ShiftListItemDto> Items,
int Total,
int Page,
int PageSize
);
public record ShiftListItemDto(
Guid Id,
string Title,
DateTimeOffset StartTime,
DateTimeOffset EndTime,
int Capacity,
int CurrentSignups
);

View File

@@ -0,0 +1,10 @@
namespace WorkClub.Application.Shifts.DTOs;
public record UpdateShiftRequest(
string? Title,
string? Description,
string? Location,
DateTimeOffset? StartTime,
DateTimeOffset? EndTime,
int? Capacity
);

View File

@@ -13,7 +13,7 @@ public abstract class IntegrationTestBase : IClassFixture<CustomWebApplicationFa
Client = factory.CreateClient();
}
protected void AuthenticateAs(string email, Dictionary<string, string> clubs)
protected void AuthenticateAs(string email, Dictionary<string, string> clubs, string? userId = null)
{
var clubsJson = JsonSerializer.Serialize(clubs);
Client.DefaultRequestHeaders.Remove("X-Test-Clubs");
@@ -21,6 +21,12 @@ public abstract class IntegrationTestBase : IClassFixture<CustomWebApplicationFa
Client.DefaultRequestHeaders.Remove("X-Test-Email");
Client.DefaultRequestHeaders.Add("X-Test-Email", email);
if (!string.IsNullOrEmpty(userId))
{
Client.DefaultRequestHeaders.Remove("X-Test-UserId");
Client.DefaultRequestHeaders.Add("X-Test-UserId", userId);
}
}
protected void SetTenant(string tenantId)

View File

@@ -21,10 +21,12 @@ public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions
{
var clubsClaim = Context.Request.Headers["X-Test-Clubs"].ToString();
var emailClaim = Context.Request.Headers["X-Test-Email"].ToString();
var userIdClaim = Context.Request.Headers["X-Test-UserId"].ToString();
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, "test-user"),
new Claim("sub", string.IsNullOrEmpty(userIdClaim) ? Guid.NewGuid().ToString() : userIdClaim),
new Claim(ClaimTypes.Email, string.IsNullOrEmpty(emailClaim) ? "test@test.com" : emailClaim),
};

View File

@@ -0,0 +1,667 @@
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);