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:
WorkClub Automation
2026-03-03 19:41:01 +01:00
parent 0ef1d0bbd4
commit db880b3480
15 changed files with 1036 additions and 1 deletions

View File

@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Http.HttpResults;
using WorkClub.Api.Services;
using WorkClub.Application.Clubs.DTOs;
namespace WorkClub.Api.Endpoints.Clubs;
public static class ClubEndpoints
{
public static void MapClubEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/clubs");
group.MapGet("/me", GetMyClubs)
.RequireAuthorization("RequireMember")
.WithName("GetMyClubs");
group.MapGet("/current", GetCurrentClub)
.RequireAuthorization("RequireMember")
.WithName("GetCurrentClub");
}
private static async Task<Ok<List<ClubListDto>>> GetMyClubs(ClubService clubService)
{
var result = await clubService.GetMyClubsAsync();
return TypedResults.Ok(result);
}
private static async Task<Results<Ok<ClubDetailDto>, NotFound>> GetCurrentClub(ClubService clubService)
{
var result = await clubService.GetCurrentClubAsync();
if (result == null)
return TypedResults.NotFound();
return TypedResults.Ok(result);
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Http.HttpResults;
using WorkClub.Api.Services;
using WorkClub.Application.Members.DTOs;
namespace WorkClub.Api.Endpoints.Members;
public static class MemberEndpoints
{
public static void MapMemberEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/members");
group.MapGet("", GetMembers)
.RequireAuthorization("RequireMember")
.WithName("GetMembers");
group.MapGet("{id:guid}", GetMemberById)
.RequireAuthorization("RequireMember")
.WithName("GetMemberById");
group.MapGet("/me", GetCurrentMember)
.RequireAuthorization("RequireMember")
.WithName("GetCurrentMember");
}
private static async Task<Ok<List<MemberListDto>>> GetMembers(MemberService memberService)
{
var result = await memberService.GetMembersAsync();
return TypedResults.Ok(result);
}
private static async Task<Results<Ok<MemberDetailDto>, NotFound>> GetMemberById(
Guid id,
MemberService memberService)
{
var result = await memberService.GetMemberByIdAsync(id);
if (result == null)
return TypedResults.NotFound();
return TypedResults.Ok(result);
}
private static async Task<Results<Ok<MemberDetailDto>, NotFound>> GetCurrentMember(MemberService memberService)
{
var result = await memberService.GetCurrentMemberAsync();
if (result == null)
return TypedResults.NotFound();
return TypedResults.Ok(result);
}
}

View File

@@ -0,0 +1,26 @@
using WorkClub.Api.Services;
namespace WorkClub.Api.Middleware;
public class MemberSyncMiddleware
{
private readonly RequestDelegate _next;
public MemberSyncMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, MemberSyncService memberSyncService)
{
try
{
await memberSyncService.EnsureMemberExistsAsync(context);
}
catch
{
}
await _next(context);
}
}

View File

@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using WorkClub.Api.Auth;
using WorkClub.Api.Endpoints.Clubs;
using WorkClub.Api.Endpoints.Members;
using WorkClub.Api.Endpoints.Shifts;
using WorkClub.Api.Endpoints.Tasks;
using WorkClub.Api.Middleware;
@@ -30,6 +32,9 @@ builder.Services.AddScoped<ITenantProvider, TenantProvider>();
builder.Services.AddScoped<SeedDataService>();
builder.Services.AddScoped<TaskService>();
builder.Services.AddScoped<ShiftService>();
builder.Services.AddScoped<ClubService>();
builder.Services.AddScoped<MemberService>();
builder.Services.AddScoped<MemberSyncService>();
builder.Services.AddSingleton<TenantDbConnectionInterceptor>();
builder.Services.AddSingleton<SaveChangesTenantInterceptor>();
@@ -91,6 +96,7 @@ app.UseAuthentication();
app.UseMultiTenant();
app.UseMiddleware<TenantValidationMiddleware>();
app.UseAuthorization();
app.UseMiddleware<MemberSyncMiddleware>();
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
@@ -124,6 +130,8 @@ app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" }))
app.MapTaskEndpoints();
app.MapShiftEndpoints();
app.MapClubEndpoints();
app.MapMemberEndpoints();
app.Run();

View File

@@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore;
using WorkClub.Application.Clubs.DTOs;
using WorkClub.Application.Interfaces;
using WorkClub.Infrastructure.Data;
namespace WorkClub.Api.Services;
public class ClubService
{
private readonly AppDbContext _context;
private readonly ITenantProvider _tenantProvider;
private readonly IHttpContextAccessor _httpContextAccessor;
public ClubService(
AppDbContext context,
ITenantProvider tenantProvider,
IHttpContextAccessor httpContextAccessor)
{
_context = context;
_tenantProvider = tenantProvider;
_httpContextAccessor = httpContextAccessor;
}
public async Task<List<ClubListDto>> GetMyClubsAsync()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userIdClaim))
{
return new List<ClubListDto>();
}
var memberships = await _context.Members
.Where(m => m.ExternalUserId == userIdClaim)
.ToListAsync();
var clubIds = memberships.Select(m => m.ClubId).ToList();
var clubs = await _context.Clubs
.Where(c => clubIds.Contains(c.Id))
.ToListAsync();
var clubDtos = new List<ClubListDto>();
foreach (var club in clubs)
{
var memberCount = await _context.Members
.Where(m => m.ClubId == club.Id)
.CountAsync();
clubDtos.Add(new ClubListDto(
club.Id,
club.Name,
club.SportType.ToString(),
memberCount
));
}
return clubDtos;
}
public async Task<ClubDetailDto?> GetCurrentClubAsync()
{
var tenantId = _tenantProvider.GetTenantId();
var club = await _context.Clubs
.FirstOrDefaultAsync(c => c.TenantId == tenantId);
if (club == null)
return null;
return new ClubDetailDto(
club.Id,
club.Name,
club.SportType.ToString(),
club.Description,
club.CreatedAt,
club.UpdatedAt
);
}
}

View File

@@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore;
using WorkClub.Application.Interfaces;
using WorkClub.Application.Members.DTOs;
using WorkClub.Infrastructure.Data;
namespace WorkClub.Api.Services;
public class MemberService
{
private readonly AppDbContext _context;
private readonly ITenantProvider _tenantProvider;
private readonly IHttpContextAccessor _httpContextAccessor;
public MemberService(
AppDbContext context,
ITenantProvider tenantProvider,
IHttpContextAccessor httpContextAccessor)
{
_context = context;
_tenantProvider = tenantProvider;
_httpContextAccessor = httpContextAccessor;
}
public async Task<List<MemberListDto>> GetMembersAsync()
{
var members = await _context.Members.ToListAsync();
return members.Select(m => new MemberListDto(
m.Id,
m.DisplayName,
m.Email,
m.Role.ToString()
)).ToList();
}
public async Task<MemberDetailDto?> GetMemberByIdAsync(Guid id)
{
var member = await _context.Members.FindAsync(id);
if (member == null)
return null;
return new MemberDetailDto(
member.Id,
member.DisplayName,
member.Email,
member.Role.ToString(),
member.ClubId,
member.CreatedAt,
member.UpdatedAt
);
}
public async Task<MemberDetailDto?> GetCurrentMemberAsync()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userIdClaim))
{
return null;
}
var tenantId = _tenantProvider.GetTenantId();
var member = await _context.Members
.FirstOrDefaultAsync(m => m.ExternalUserId == userIdClaim && m.TenantId == tenantId);
if (member == null)
return null;
return new MemberDetailDto(
member.Id,
member.DisplayName,
member.Email,
member.Role.ToString(),
member.ClubId,
member.CreatedAt,
member.UpdatedAt
);
}
}

View File

@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore;
using WorkClub.Application.Interfaces;
using WorkClub.Domain.Entities;
using WorkClub.Domain.Enums;
using WorkClub.Infrastructure.Data;
namespace WorkClub.Api.Services;
public class MemberSyncService
{
private readonly AppDbContext _context;
private readonly ITenantProvider _tenantProvider;
public MemberSyncService(AppDbContext context, ITenantProvider tenantProvider)
{
_context = context;
_tenantProvider = tenantProvider;
}
public async Task EnsureMemberExistsAsync(HttpContext httpContext)
{
if (httpContext?.User?.Identity?.IsAuthenticated != true)
{
return;
}
var externalUserId = httpContext.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(externalUserId))
{
return;
}
var tenantId = _tenantProvider.GetTenantId();
var existingMember = await _context.Members
.FirstOrDefaultAsync(m => m.ExternalUserId == externalUserId && m.TenantId == tenantId);
if (existingMember != null)
{
return;
}
var email = httpContext.User.FindFirst("email")?.Value ?? httpContext.User.FindFirst("preferred_username")?.Value ?? "unknown@example.com";
var name = httpContext.User.FindFirst("name")?.Value ?? email.Split('@')[0];
var roleClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ?? "Member";
var clubRole = roleClaim.ToLowerInvariant() switch
{
"admin" => ClubRole.Admin,
"manager" => ClubRole.Manager,
"member" => ClubRole.Member,
"viewer" => ClubRole.Viewer,
_ => ClubRole.Member
};
var club = await _context.Clubs.FirstOrDefaultAsync(c => c.TenantId == tenantId);
if (club == null)
{
return;
}
var newMember = new Member
{
Id = Guid.NewGuid(),
TenantId = tenantId,
ExternalUserId = externalUserId,
DisplayName = name,
Email = email,
Role = clubRole,
ClubId = club.Id,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
_context.Members.Add(newMember);
await _context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,9 @@
namespace WorkClub.Application.Clubs.DTOs;
public record ClubDetailDto(
Guid Id,
string Name,
string SportType,
string? Description,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);

View File

@@ -0,0 +1,7 @@
namespace WorkClub.Application.Clubs.DTOs;
public record ClubListDto(
Guid Id,
string Name,
string SportType,
int MemberCount);

View File

@@ -0,0 +1,10 @@
namespace WorkClub.Application.Members.DTOs;
public record MemberDetailDto(
Guid Id,
string DisplayName,
string Email,
string Role,
Guid ClubId,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);

View File

@@ -0,0 +1,7 @@
namespace WorkClub.Application.Members.DTOs;
public record MemberListDto(
Guid Id,
string DisplayName,
string Email,
string Role);

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

View File

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