feat(clubs): add Club and Member API endpoints with auto-sync
Implement Task 16: Club + Member API endpoints with MemberSyncService
Services:
- ClubService: GetMyClubsAsync (user's clubs), GetCurrentClubAsync (tenant club)
- MemberService: GetMembersAsync (list), GetMemberByIdAsync, GetCurrentMemberAsync
- MemberSyncService: Auto-creates Member records from JWT on first request
Middleware:
- MemberSyncMiddleware: Runs after auth, calls MemberSyncService
Endpoints:
- GET /api/clubs/me (list user's clubs)
- GET /api/clubs/current (current tenant's club)
- GET /api/members (list members, RLS filtered)
- GET /api/members/{id} (member detail)
- GET /api/members/me (current user's membership)
Tests: 14 integration tests (6 club + 8 member)
- Club filtering by user membership
- Multi-tenant isolation via RLS
- Member auto-sync on first request
- Cross-tenant access blocked
- Role-based authorization
Build: 0 errors, all tests compile
Pattern: TypedResults, RequireAuthorization policies, TDD approach
This commit is contained in:
225
backend/WorkClub.Tests.Integration/Clubs/ClubEndpointsTests.cs
Normal file
225
backend/WorkClub.Tests.Integration/Clubs/ClubEndpointsTests.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
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.Clubs;
|
||||
|
||||
public class ClubEndpointsTests : IntegrationTestBase
|
||||
{
|
||||
public ClubEndpointsTests(CustomWebApplicationFactory<Program> factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task InitializeAsync()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
// Clean up and setup test data
|
||||
context.Clubs.RemoveRange(context.Clubs);
|
||||
context.Members.RemoveRange(context.Members);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Create test clubs
|
||||
var club1Id = Guid.NewGuid();
|
||||
var club2Id = Guid.NewGuid();
|
||||
|
||||
var club1 = new Club
|
||||
{
|
||||
Id = club1Id,
|
||||
TenantId = "tenant1",
|
||||
Name = "Test Tennis Club",
|
||||
SportType = SportType.Tennis,
|
||||
Description = "Test club 1",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var club2 = new Club
|
||||
{
|
||||
Id = club2Id,
|
||||
TenantId = "tenant2",
|
||||
Name = "Test Cycling Club",
|
||||
SportType = SportType.Cycling,
|
||||
Description = "Test club 2",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
context.Clubs.AddRange(club1, club2);
|
||||
|
||||
// Create test members (membership links)
|
||||
var adminUserId = "admin-user-id";
|
||||
var managerUserId = "manager-user-id";
|
||||
|
||||
// Admin is member of both clubs
|
||||
context.Members.Add(new Member
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant1",
|
||||
ExternalUserId = adminUserId,
|
||||
DisplayName = "Admin User",
|
||||
Email = "admin@test.com",
|
||||
Role = ClubRole.Admin,
|
||||
ClubId = club1Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
context.Members.Add(new Member
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant2",
|
||||
ExternalUserId = adminUserId,
|
||||
DisplayName = "Admin User",
|
||||
Email = "admin@test.com",
|
||||
Role = ClubRole.Member,
|
||||
ClubId = club2Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
// Manager is only member of club1
|
||||
context.Members.Add(new Member
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant1",
|
||||
ExternalUserId = managerUserId,
|
||||
DisplayName = "Manager User",
|
||||
Email = "manager@test.com",
|
||||
Role = ClubRole.Manager,
|
||||
ClubId = club1Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClubsMe_ReturnsOnlyUserClubs()
|
||||
{
|
||||
// Arrange - admin is member of 2 clubs
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("admin@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant1"] = "Admin",
|
||||
["tenant2"] = "Member"
|
||||
}, userId: "admin-user-id");
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync("/api/clubs/me");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var clubs = await response.Content.ReadFromJsonAsync<List<ClubListResponse>>();
|
||||
Assert.NotNull(clubs);
|
||||
Assert.Equal(2, clubs.Count);
|
||||
Assert.Contains(clubs, c => c.Name == "Test Tennis Club");
|
||||
Assert.Contains(clubs, c => c.Name == "Test Cycling Club");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClubsMe_ForManagerUser_ReturnsOnlyOneClub()
|
||||
{
|
||||
// Arrange - manager is only member of club1
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("manager@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant1"] = "Manager"
|
||||
}, userId: "manager-user-id");
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync("/api/clubs/me");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var clubs = await response.Content.ReadFromJsonAsync<List<ClubListResponse>>();
|
||||
Assert.NotNull(clubs);
|
||||
Assert.Single(clubs);
|
||||
Assert.Equal("Test Tennis Club", clubs[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClubsCurrent_ReturnsCurrentTenantClub()
|
||||
{
|
||||
// Arrange
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("admin@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant1"] = "Admin"
|
||||
}, userId: "admin-user-id");
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync("/api/clubs/current");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var club = await response.Content.ReadFromJsonAsync<ClubDetailResponse>();
|
||||
Assert.NotNull(club);
|
||||
Assert.Equal("Test Tennis Club", club.Name);
|
||||
Assert.Equal("Tennis", club.SportType);
|
||||
Assert.Equal("Test club 1", club.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClubsCurrent_DifferentTenant_ReturnsDifferentClub()
|
||||
{
|
||||
// Arrange
|
||||
SetTenant("tenant2");
|
||||
AuthenticateAs("admin@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant2"] = "Member"
|
||||
}, userId: "admin-user-id");
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync("/api/clubs/current");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var club = await response.Content.ReadFromJsonAsync<ClubDetailResponse>();
|
||||
Assert.NotNull(club);
|
||||
Assert.Equal("Test Cycling Club", club.Name);
|
||||
Assert.Equal("Cycling", club.SportType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClubsCurrent_NoTenantContext_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange - no tenant header set
|
||||
AuthenticateAs("admin@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant1"] = "Admin"
|
||||
}, userId: "admin-user-id");
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync("/api/clubs/current");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClubsMe_Unauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
// Act
|
||||
var response = await Client.GetAsync("/api/clubs/me");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Response DTOs for test assertions
|
||||
public record ClubListResponse(Guid Id, string Name, string SportType, int MemberCount);
|
||||
public record ClubDetailResponse(Guid Id, string Name, string SportType, string? Description, DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,271 @@
|
||||
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.Members;
|
||||
|
||||
public class MemberEndpointsTests : IntegrationTestBase
|
||||
{
|
||||
public MemberEndpointsTests(CustomWebApplicationFactory<Program> factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task InitializeAsync()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
context.Members.RemoveRange(context.Members);
|
||||
context.Clubs.RemoveRange(context.Clubs);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var club1Id = Guid.NewGuid();
|
||||
var club2Id = Guid.NewGuid();
|
||||
|
||||
context.Clubs.AddRange(
|
||||
new Club
|
||||
{
|
||||
Id = club1Id,
|
||||
TenantId = "tenant1",
|
||||
Name = "Test Tennis Club",
|
||||
SportType = SportType.Tennis,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new Club
|
||||
{
|
||||
Id = club2Id,
|
||||
TenantId = "tenant2",
|
||||
Name = "Test Cycling Club",
|
||||
SportType = SportType.Cycling,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
var adminId = Guid.NewGuid();
|
||||
var managerId = Guid.NewGuid();
|
||||
var member1Id = Guid.NewGuid();
|
||||
|
||||
context.Members.AddRange(
|
||||
new Member
|
||||
{
|
||||
Id = adminId,
|
||||
TenantId = "tenant1",
|
||||
ExternalUserId = "admin-user-id",
|
||||
DisplayName = "Admin User",
|
||||
Email = "admin@test.com",
|
||||
Role = ClubRole.Admin,
|
||||
ClubId = club1Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new Member
|
||||
{
|
||||
Id = managerId,
|
||||
TenantId = "tenant1",
|
||||
ExternalUserId = "manager-user-id",
|
||||
DisplayName = "Manager User",
|
||||
Email = "manager@test.com",
|
||||
Role = ClubRole.Manager,
|
||||
ClubId = club1Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new Member
|
||||
{
|
||||
Id = member1Id,
|
||||
TenantId = "tenant1",
|
||||
ExternalUserId = "member1-user-id",
|
||||
DisplayName = "Member One",
|
||||
Email = "member1@test.com",
|
||||
Role = ClubRole.Member,
|
||||
ClubId = club1Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new Member
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant2",
|
||||
ExternalUserId = "other-user-id",
|
||||
DisplayName = "Other User",
|
||||
Email = "other@test.com",
|
||||
Role = ClubRole.Member,
|
||||
ClubId = club2Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMembers_ReturnsOnlyCurrentTenantMembers()
|
||||
{
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("admin@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant1"] = "Admin"
|
||||
}, userId: "admin-user-id");
|
||||
|
||||
var response = await Client.GetAsync("/api/members");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var members = await response.Content.ReadFromJsonAsync<List<MemberListResponse>>();
|
||||
Assert.NotNull(members);
|
||||
Assert.Equal(3, members.Count);
|
||||
Assert.DoesNotContain(members, m => m.Email == "other@test.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMembers_DifferentTenant_ReturnsDifferentMembers()
|
||||
{
|
||||
SetTenant("tenant2");
|
||||
AuthenticateAs("other@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant2"] = "Member"
|
||||
}, userId: "other-user-id");
|
||||
|
||||
var response = await Client.GetAsync("/api/members");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var members = await response.Content.ReadFromJsonAsync<List<MemberListResponse>>();
|
||||
Assert.NotNull(members);
|
||||
Assert.Single(members);
|
||||
Assert.Equal("other@test.com", members[0].Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMembers_AsViewer_ReturnsForbidden()
|
||||
{
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("viewer@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant1"] = "Viewer"
|
||||
}, userId: "viewer-user-id");
|
||||
|
||||
var response = await Client.GetAsync("/api/members");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMemberById_ExistingMember_ReturnsMemberDetail()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var member = await context.Members.FirstAsync(m => m.Email == "manager@test.com");
|
||||
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("admin@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant1"] = "Admin"
|
||||
}, userId: "admin-user-id");
|
||||
|
||||
var response = await Client.GetAsync($"/api/members/{member.Id}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<MemberDetailResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(member.Id, result.Id);
|
||||
Assert.Equal("Manager User", result.DisplayName);
|
||||
Assert.Equal("manager@test.com", result.Email);
|
||||
Assert.Equal("Manager", result.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMemberById_WrongTenant_ReturnsNotFound()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var tenant1Member = await context.Members.FirstAsync(m => m.TenantId == "tenant1");
|
||||
|
||||
SetTenant("tenant2");
|
||||
AuthenticateAs("other@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant2"] = "Member"
|
||||
}, userId: "other-user-id");
|
||||
|
||||
var response = await Client.GetAsync($"/api/members/{tenant1Member.Id}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMembersMe_ReturnsCurrentUserMembership()
|
||||
{
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("manager@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant1"] = "Manager"
|
||||
}, userId: "manager-user-id");
|
||||
|
||||
var response = await Client.GetAsync("/api/members/me");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<MemberDetailResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Manager User", result.DisplayName);
|
||||
Assert.Equal("manager@test.com", result.Email);
|
||||
Assert.Equal("Manager", result.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MemberAutoSync_NewUser_CreatesMembeRecordFromJwt()
|
||||
{
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("newuser@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant1"] = "Member"
|
||||
}, userId: "new-user-id");
|
||||
|
||||
var response = await Client.GetAsync("/api/members/me");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var syncedMember = await context.Members.FirstOrDefaultAsync(m => m.ExternalUserId == "new-user-id");
|
||||
|
||||
Assert.NotNull(syncedMember);
|
||||
Assert.Equal("newuser@test.com", syncedMember.Email);
|
||||
Assert.Equal("tenant1", syncedMember.TenantId);
|
||||
Assert.Equal(ClubRole.Member, syncedMember.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MemberAutoSync_ExistingUser_DoesNotDuplicate()
|
||||
{
|
||||
using var scope1 = Factory.Services.CreateScope();
|
||||
var context1 = scope1.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var initialCount = await context1.Members.CountAsync(m => m.ExternalUserId == "admin-user-id");
|
||||
|
||||
SetTenant("tenant1");
|
||||
AuthenticateAs("admin@test.com", new Dictionary<string, string>
|
||||
{
|
||||
["tenant1"] = "Admin"
|
||||
}, userId: "admin-user-id");
|
||||
|
||||
var response = await Client.GetAsync("/api/members/me");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var scope2 = Factory.Services.CreateScope();
|
||||
var context2 = scope2.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var finalCount = await context2.Members.CountAsync(m => m.ExternalUserId == "admin-user-id");
|
||||
|
||||
Assert.Equal(initialCount, finalCount);
|
||||
}
|
||||
}
|
||||
|
||||
public record MemberListResponse(Guid Id, string DisplayName, string Email, string Role);
|
||||
public record MemberDetailResponse(Guid Id, string DisplayName, string Email, string Role, Guid ClubId, DateTimeOffset CreatedAt);
|
||||
Reference in New Issue
Block a user