Files
work-club-manager/backend/WorkClub.Tests.Integration/Shifts/ShiftCrudTests.cs

668 lines
22 KiB
C#
Raw Normal View History

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);