diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index 309f036..20c1e0d 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -19,13 +19,13 @@ jobs: - name: ⚙️ Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: '7.0.x' + dotnet-version: '9.0.x' - name: ⚒ Build solution run: dotnet build - name: ⚙️ Restore .NET tools run: dotnet tool restore - name: 📝 Produce OpenAPI specification - run: dotnet swagger tofile --output openapi.yaml --yaml ./src/CrowdParlay.Users.Api/bin/Debug/net7.0/CrowdParlay.Users.Api.dll v1 + run: dotnet swagger tofile --output openapi.yaml --yaml ./src/CrowdParlay.Social.Api/bin/Debug/net9.0/CrowdParlay.Social.Api.dll v1 - name: 🔗 Upload OpenAPI specification as release asset uses: actions/upload-release-asset@v1 env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 80a36b1..6700a40 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: - name: ⚙️ Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 7.0.x + dotnet-version: 9.0.x - name: 📥 Restore dependencies run: dotnet restore - name: ⚒ Build solution diff --git a/Dockerfile b/Dockerfile index b9eb825..bb7e41d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build WORKDIR /app COPY /src . RUN dotnet restore "CrowdParlay.Users.Api/CrowdParlay.Users.Api.csproj" RUN dotnet publish "CrowdParlay.Users.Api/CrowdParlay.Users.Api.csproj" -c Release -o out -FROM mcr.microsoft.com/dotnet/aspnet:7.0 +FROM mcr.microsoft.com/dotnet/aspnet:9.0 WORKDIR /app COPY --from=build /app/out . ENTRYPOINT ["dotnet", "CrowdParlay.Users.Api.dll"] diff --git a/src/CrowdParlay.Users.Api/CrowdParlay.Users.Api.csproj b/src/CrowdParlay.Users.Api/CrowdParlay.Users.Api.csproj index a61196d..dca42e8 100644 --- a/src/CrowdParlay.Users.Api/CrowdParlay.Users.Api.csproj +++ b/src/CrowdParlay.Users.Api/CrowdParlay.Users.Api.csproj @@ -1,7 +1,7 @@ - net7.0 + net9.0 enable enable Linux @@ -10,31 +10,30 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - + + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - + + + + + + + diff --git a/src/CrowdParlay.Users.Api/Extensions/HttpContextExtensions.cs b/src/CrowdParlay.Users.Api/Extensions/HttpContextExtensions.cs index a1efdcc..9a0f23a 100644 --- a/src/CrowdParlay.Users.Api/Extensions/HttpContextExtensions.cs +++ b/src/CrowdParlay.Users.Api/Extensions/HttpContextExtensions.cs @@ -1,5 +1,4 @@ using System.Security.Claims; -using Dodo.Primitives; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using OpenIddict.Abstractions; @@ -9,16 +8,16 @@ namespace CrowdParlay.Users.Api.Extensions; public static class HttpContextExtensions { - public static Uuid? GetUserId(this HttpContext context) + public static Guid? GetUserId(this HttpContext context) { var userId = context.User.GetClaim(CookieAuthenticationConstants.UserIdClaim) ?? context.User.GetClaim(OpenIddictConstants.Claims.Subject); - return Uuid.TryParse(userId, out var value) ? value : null; + return Guid.TryParse(userId, out var value) ? value : null; } - public static async Task SignInAsync(this HttpContext context, string authenticationScheme, Uuid userId) + public static async Task SignInAsync(this HttpContext context, string authenticationScheme, Guid userId) { var userIdClaimType = authenticationScheme switch { diff --git a/src/CrowdParlay.Users.Api/Services/KebabCaseParameterPolicy.cs b/src/CrowdParlay.Users.Api/Services/KebabCaseParameterPolicy.cs index e21d4e8..3e64d9c 100644 --- a/src/CrowdParlay.Users.Api/Services/KebabCaseParameterPolicy.cs +++ b/src/CrowdParlay.Users.Api/Services/KebabCaseParameterPolicy.cs @@ -4,7 +4,7 @@ namespace CrowdParlay.Users.Api.Services; /// /// An endpoint parameter transformer that applies kebab-case naming convention to endpoint routes and parameter names. -/// For example, turns '/Animal/8/AddType' into '/animals/8/add-type'. +/// For example, turns '/Animals/8/AddType' into '/animals/8/add-type'. /// public partial class KebabCaseParameterPolicy : IOutboundParameterTransformer { diff --git a/src/CrowdParlay.Users.Api/Services/OpenIddict/RefreshTokenGrantEventHandler.cs b/src/CrowdParlay.Users.Api/Services/OpenIddict/RefreshTokenGrantEventHandler.cs index 72ea6a3..1029ad0 100644 --- a/src/CrowdParlay.Users.Api/Services/OpenIddict/RefreshTokenGrantEventHandler.cs +++ b/src/CrowdParlay.Users.Api/Services/OpenIddict/RefreshTokenGrantEventHandler.cs @@ -2,7 +2,6 @@ using System.Security.Claims; using CrowdParlay.Users.Api.Extensions; using CrowdParlay.Users.Domain.Abstractions; -using Dodo.Primitives; using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; using OpenIddict.Server; @@ -23,7 +22,7 @@ public async ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestCont return; var jwt = _jwtHandler.ReadJwtToken(context.Request.RefreshToken); - var userId = Uuid.Parse(jwt.Subject); + var userId = Guid.Parse(jwt.Subject); var user = await _usersRepository.GetByIdAsync(userId); if (user is null) { diff --git a/src/CrowdParlay.Users.Api/Services/gRPC/UsersService.cs b/src/CrowdParlay.Users.Api/Services/gRPC/UsersService.cs index 3552367..6d2ed78 100644 --- a/src/CrowdParlay.Users.Api/Services/gRPC/UsersService.cs +++ b/src/CrowdParlay.Users.Api/Services/gRPC/UsersService.cs @@ -1,6 +1,5 @@ using CrowdParlay.Users.Domain.Abstractions; using CrowdParlay.Users.gRPC; -using Dodo.Primitives; using Google.Protobuf.WellKnownTypes; using Google.Rpc; using Grpc.Core; @@ -17,7 +16,7 @@ public class UsersGrpcService : UsersService.UsersServiceBase public override async Task GetUser(GetUserRequest request, ServerCallContext context) { - var user = await _users.GetByIdAsync(Uuid.Parse(request.Id)); + var user = await _users.GetByIdAsync(Guid.Parse(request.Id)); return user.Adapt(); } diff --git a/src/CrowdParlay.Users.Api/Swagger/SwaggerWebHostFactory.cs b/src/CrowdParlay.Users.Api/Swagger/SwaggerWebHostFactory.cs index 2cc6c30..b2daa6e 100644 --- a/src/CrowdParlay.Users.Api/Swagger/SwaggerWebHostFactory.cs +++ b/src/CrowdParlay.Users.Api/Swagger/SwaggerWebHostFactory.cs @@ -1,6 +1,5 @@ using System.Reflection; using CrowdParlay.Users.Api.Extensions; -using Dodo.Primitives; using Microsoft.AspNetCore; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; @@ -33,7 +32,7 @@ public static IWebHost CreateWebHost() => WebHost.CreateDefaultBuilder() options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlDocsFileName)); options.SupportNonNullableReferenceTypes(); - options.MapType(() => new OpenApiSchema + options.MapType(() => new OpenApiSchema { Type = "string", Format = "uuid" diff --git a/src/CrowdParlay.Users.Api/v1/Controllers/UsersController.cs b/src/CrowdParlay.Users.Api/v1/Controllers/UsersController.cs index 9045d43..bec1a8c 100644 --- a/src/CrowdParlay.Users.Api/v1/Controllers/UsersController.cs +++ b/src/CrowdParlay.Users.Api/v1/Controllers/UsersController.cs @@ -8,7 +8,7 @@ using CrowdParlay.Users.Application.Features.Users.Commands; using CrowdParlay.Users.Application.Features.Users.Queries; using CrowdParlay.Users.Application.Models; -using Dodo.Primitives; +using CrowdParlay.Users.Domain; using Mapster; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; @@ -26,6 +26,17 @@ public class UsersController : ApiControllerBase public UsersController(IDataProtectionProvider dataProtectionProvider) => _externalLoginTicketProtector = dataProtectionProvider.CreateProtector(ExternalLoginTicketsConstants.DataProtectionPurpose); + /// + /// Returns users. + /// + [HttpGet] + [Consumes(MediaTypeNames.Application.Json), Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(Page), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.InternalServerError)] + [ProducesResponseType(typeof(ValidationProblem), (int)HttpStatusCode.BadRequest)] + public async Task> Search(SortingStrategy order, int offset, int count) => + await Mediator.Send(new Search.Query(order, offset, count)); + /// /// Creates a user. /// @@ -74,7 +85,7 @@ public UsersController(IDataProtectionProvider dataProtectionProvider) => [ProducesResponseType(typeof(GetById.Response), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.InternalServerError)] [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.NotFound)] - public async Task GetById([FromRoute] Uuid userId) => + public async Task GetById([FromRoute] Guid userId) => await Mediator.Send(new GetById.Query(userId)); /// @@ -85,11 +96,8 @@ public UsersController(IDataProtectionProvider dataProtectionProvider) => [ProducesResponseType(typeof(GetById.Response), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.InternalServerError)] [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.NotFound)] - public async Task Self() - { - var user = await Mediator.Send(new GetById.Query(HttpContext.GetUserId()!.Value)); - return user.Adapt(); - } + public async Task Self() => + await Mediator.Send(new GetById.Query(HttpContext.GetUserId()!.Value)); /// /// Returns user with the specified username. @@ -113,7 +121,7 @@ public async Task Self() [ProducesResponseType(typeof(ValidationProblem), (int)HttpStatusCode.BadRequest)] [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.NotFound)] - public async Task Update([FromRoute] Uuid userId, [FromBody] UsersUpdateRequest request) + public async Task Update([FromRoute] Guid userId, [FromBody] UsersUpdateRequest request) { if (userId != HttpContext.GetUserId()) throw new ForbiddenException(); @@ -130,7 +138,7 @@ public async Task Self() [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.InternalServerError)] [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(Problem), (int)HttpStatusCode.NotFound)] - public async Task Delete([FromRoute] Uuid userId) + public async Task Delete([FromRoute] Guid userId) { if (userId != HttpContext.GetUserId()) throw new ForbiddenException(); diff --git a/src/CrowdParlay.Users.Api/v1/DTOs/UsersDTOs.cs b/src/CrowdParlay.Users.Api/v1/DTOs/UsersDTOs.cs index cfd6ce7..cf53fd9 100644 --- a/src/CrowdParlay.Users.Api/v1/DTOs/UsersDTOs.cs +++ b/src/CrowdParlay.Users.Api/v1/DTOs/UsersDTOs.cs @@ -1,9 +1,7 @@ -using Dodo.Primitives; - namespace CrowdParlay.Users.Api.v1.DTOs; public sealed record UserInfoResponse( - Uuid Id, + Guid Id, string Username, string DisplayName, string? AvatarUrl); diff --git a/src/CrowdParlay.Users.Application/CrowdParlay.Users.Application.csproj b/src/CrowdParlay.Users.Application/CrowdParlay.Users.Application.csproj index 27324d1..6751c52 100644 --- a/src/CrowdParlay.Users.Application/CrowdParlay.Users.Application.csproj +++ b/src/CrowdParlay.Users.Application/CrowdParlay.Users.Application.csproj @@ -1,27 +1,27 @@  - net7.0 + net9.0 enable enable - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + diff --git a/src/CrowdParlay.Users.Application/Features/Users/Commands/Delete.cs b/src/CrowdParlay.Users.Application/Features/Users/Commands/Delete.cs index ae821b4..c160207 100644 --- a/src/CrowdParlay.Users.Application/Features/Users/Commands/Delete.cs +++ b/src/CrowdParlay.Users.Application/Features/Users/Commands/Delete.cs @@ -1,6 +1,5 @@ using CrowdParlay.Communication; using CrowdParlay.Users.Domain.Abstractions; -using Dodo.Primitives; using FluentValidation; using MassTransit; using Mediator; @@ -9,7 +8,7 @@ namespace CrowdParlay.Users.Application.Features.Users.Commands; public static class Delete { - public sealed record Command(Uuid Id) : IRequest; + public sealed record Command(Guid Id) : IRequest; public sealed class Validator : AbstractValidator { diff --git a/src/CrowdParlay.Users.Application/Features/Users/Commands/Register.cs b/src/CrowdParlay.Users.Application/Features/Users/Commands/Register.cs index 4c6be72..7e04405 100644 --- a/src/CrowdParlay.Users.Application/Features/Users/Commands/Register.cs +++ b/src/CrowdParlay.Users.Application/Features/Users/Commands/Register.cs @@ -4,7 +4,6 @@ using CrowdParlay.Users.Application.Models; using CrowdParlay.Users.Domain.Abstractions; using CrowdParlay.Users.Domain.Entities; -using Dodo.Primitives; using FluentValidation; using MassTransit; using Mediator; @@ -91,7 +90,7 @@ public async ValueTask Handle(Command request, CancellationToken cance var user = new User { - Id = Uuid.NewTimeBased(), + Id = Guid.CreateVersion7(), Username = request.Username, Email = request.Email, DisplayName = request.DisplayName, @@ -110,7 +109,7 @@ public async ValueTask Handle(Command request, CancellationToken cance var externalLogin = new ExternalLogin { - Id = Uuid.NewTimeBased(), + Id = Guid.CreateVersion7(), UserId = user.Id, ProviderId = request.ExternalLoginTicket.ProviderId, Identity = request.ExternalLoginTicket.Identity @@ -122,7 +121,7 @@ public async ValueTask Handle(Command request, CancellationToken cance } public sealed record Response( - Uuid Id, + Guid Id, string Username, string Email, string DisplayName, diff --git a/src/CrowdParlay.Users.Application/Features/Users/Commands/Update.cs b/src/CrowdParlay.Users.Application/Features/Users/Commands/Update.cs index 164381f..217eb54 100644 --- a/src/CrowdParlay.Users.Application/Features/Users/Commands/Update.cs +++ b/src/CrowdParlay.Users.Application/Features/Users/Commands/Update.cs @@ -3,7 +3,6 @@ using CrowdParlay.Users.Application.Exceptions; using CrowdParlay.Users.Application.Extensions; using CrowdParlay.Users.Domain.Abstractions; -using Dodo.Primitives; using FluentValidation; using MassTransit; using Mediator; @@ -14,7 +13,7 @@ namespace CrowdParlay.Users.Application.Features.Users.Commands; public static class Update { public sealed record Command( - Uuid Id, + Guid Id, string? Username, string? DisplayName, string? Email, @@ -103,7 +102,7 @@ await _users.GetByIdAsync(request.Id, cancellationToken) } public sealed record Response( - Uuid Id, + Guid Id, string Username, string DisplayName, string Email, diff --git a/src/CrowdParlay.Users.Application/Features/Users/Queries/GetById.cs b/src/CrowdParlay.Users.Application/Features/Users/Queries/GetById.cs index 7e476be..5720515 100644 --- a/src/CrowdParlay.Users.Application/Features/Users/Queries/GetById.cs +++ b/src/CrowdParlay.Users.Application/Features/Users/Queries/GetById.cs @@ -1,6 +1,5 @@ using CrowdParlay.Users.Application.Exceptions; using CrowdParlay.Users.Domain.Abstractions; -using Dodo.Primitives; using FluentValidation; using Mediator; @@ -8,7 +7,7 @@ namespace CrowdParlay.Users.Application.Features.Users.Queries; public static class GetById { - public sealed record Query(Uuid Id) : IRequest; + public sealed record Query(Guid Id) : IRequest; public sealed class Validator : AbstractValidator { @@ -31,5 +30,5 @@ await _users.GetByIdAsync(request.Id, cancellationToken) } } - public sealed record Response(Uuid Id, string Username, string DisplayName, string? AvatarUrl); + public sealed record Response(Guid Id, string Username, string DisplayName, string? AvatarUrl); } \ No newline at end of file diff --git a/src/CrowdParlay.Users.Application/Features/Users/Queries/GetByUsername.cs b/src/CrowdParlay.Users.Application/Features/Users/Queries/GetByUsername.cs index e18c298..6d8d3b3 100644 --- a/src/CrowdParlay.Users.Application/Features/Users/Queries/GetByUsername.cs +++ b/src/CrowdParlay.Users.Application/Features/Users/Queries/GetByUsername.cs @@ -1,20 +1,13 @@ using CrowdParlay.Users.Application.Exceptions; -using CrowdParlay.Users.Application.Extensions; using CrowdParlay.Users.Domain.Abstractions; -using Dodo.Primitives; -using FluentValidation; using Mediator; namespace CrowdParlay.Users.Application.Features.Users.Queries; + public static class GetByUsername { public sealed record Query(string Username) : IRequest; - public sealed class Validator : AbstractValidator - { - public Validator() => RuleFor(x => x.Username).Username(); - } - public sealed class Handler : IRequestHandler { private readonly IUsersRepository _users; @@ -32,5 +25,5 @@ await _users.GetByUsernameExactAsync(request.Username, cancellationToken) } - public sealed record Response(Uuid Id, string Username, string DisplayName, string? AvatarUrl); -} + public sealed record Response(Guid Id, string Username, string DisplayName, string? AvatarUrl); +} \ No newline at end of file diff --git a/src/CrowdParlay.Users.Application/Features/Users/Queries/Search.cs b/src/CrowdParlay.Users.Application/Features/Users/Queries/Search.cs new file mode 100644 index 0000000..5e69641 --- /dev/null +++ b/src/CrowdParlay.Users.Application/Features/Users/Queries/Search.cs @@ -0,0 +1,41 @@ +using CrowdParlay.Users.Domain; +using CrowdParlay.Users.Domain.Abstractions; +using FluentValidation; +using Mediator; + +namespace CrowdParlay.Users.Application.Features.Users.Queries; + +public static class Search +{ + public sealed record Query(SortingStrategy Order, int Offset, int Count) : IRequest>; + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Offset).GreaterThanOrEqualTo(0); + RuleFor(x => x.Count).InclusiveBetween(1, 100); + } + } + + public sealed class Handler : IRequestHandler> + { + private readonly IUsersRepository _users; + + public Handler(IUsersRepository users) => _users = users; + + public async ValueTask> Handle(Query request, CancellationToken cancellationToken) + { + var page = await _users.SearchAsync(request.Order, request.Offset, request.Count, cancellationToken); + return new Page( + page.TotalCount, + page.Items.Select(user => new Response( + user.Id, + user.Username, + user.DisplayName, + user.AvatarUrl))); + } + } + + public sealed record Response(Guid Id, string Username, string DisplayName, string? AvatarUrl); +} \ No newline at end of file diff --git a/src/CrowdParlay.Users.Application/Services/GoogleAuthenticationService.cs b/src/CrowdParlay.Users.Application/Services/GoogleAuthenticationService.cs index 4a8af8d..84b8f99 100644 --- a/src/CrowdParlay.Users.Application/Services/GoogleAuthenticationService.cs +++ b/src/CrowdParlay.Users.Application/Services/GoogleAuthenticationService.cs @@ -2,7 +2,6 @@ using CrowdParlay.Users.Application.Models; using CrowdParlay.Users.Domain.Abstractions; using CrowdParlay.Users.Domain.Entities; -using Dodo.Primitives; using Microsoft.Extensions.Logging; using static CrowdParlay.Users.Application.Services.GoogleAuthenticationStatus; @@ -59,7 +58,7 @@ public async Task AuthenticateUserByAuthorizationCod var login = new ExternalLogin { - Id = Uuid.NewTimeBased(), + Id = Guid.CreateVersion7(), UserId = user.Id, ProviderId = GoogleAuthenticationConstants.ExternalLoginProviderId, Identity = googleUserInfo.Email diff --git a/src/CrowdParlay.Users.Domain/Abstractions/IAsyncGenericRepository.cs b/src/CrowdParlay.Users.Domain/Abstractions/IAsyncGenericRepository.cs index ba36812..53dabd0 100644 --- a/src/CrowdParlay.Users.Domain/Abstractions/IAsyncGenericRepository.cs +++ b/src/CrowdParlay.Users.Domain/Abstractions/IAsyncGenericRepository.cs @@ -5,7 +5,6 @@ namespace CrowdParlay.Users.Domain.Abstractions; public interface IAsyncGenericRepository where TEntity : EntityBase where TKey : notnull { public Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default); - public Task> GetManyAsync(int count, int page = 0, CancellationToken cancellationToken = default); public Task AddAsync(TEntity entity, CancellationToken cancellationToken = default); public Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default); public Task DeleteAsync(TKey id, CancellationToken cancellationToken = default); diff --git a/src/CrowdParlay.Users.Domain/Abstractions/IExternalLoginsRepository.cs b/src/CrowdParlay.Users.Domain/Abstractions/IExternalLoginsRepository.cs index b45b154..2f493c5 100644 --- a/src/CrowdParlay.Users.Domain/Abstractions/IExternalLoginsRepository.cs +++ b/src/CrowdParlay.Users.Domain/Abstractions/IExternalLoginsRepository.cs @@ -1,11 +1,10 @@ using CrowdParlay.Users.Domain.Entities; -using Dodo.Primitives; namespace CrowdParlay.Users.Domain.Abstractions; public interface IExternalLoginsRepository { - public Task> GetByUserIdAsync(Uuid userId, CancellationToken cancellationToken = default); + public Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); public Task AddAsync(ExternalLogin entity, CancellationToken cancellationToken = default); - public Task DeleteAsync(Uuid id, CancellationToken cancellationToken = default); + public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/CrowdParlay.Users.Domain/Abstractions/IUsersRepository.cs b/src/CrowdParlay.Users.Domain/Abstractions/IUsersRepository.cs index eebc248..fc31e4c 100644 --- a/src/CrowdParlay.Users.Domain/Abstractions/IUsersRepository.cs +++ b/src/CrowdParlay.Users.Domain/Abstractions/IUsersRepository.cs @@ -1,10 +1,10 @@ using CrowdParlay.Users.Domain.Entities; -using Dodo.Primitives; namespace CrowdParlay.Users.Domain.Abstractions; -public interface IUsersRepository : IAsyncGenericRepository +public interface IUsersRepository : IAsyncGenericRepository { + public Task> SearchAsync(SortingStrategy order, int offset, int count, CancellationToken cancellationToken = default); public IAsyncEnumerable GetByIdsAsync(Guid[] ids, CancellationToken cancellationToken = default); public Task GetByUsernameExactAsync(string username, CancellationToken cancellationToken = default); public Task GetByUsernameNormalizedAsync(string username, CancellationToken cancellationToken = default); diff --git a/src/CrowdParlay.Users.Domain/CrowdParlay.Users.Domain.csproj b/src/CrowdParlay.Users.Domain/CrowdParlay.Users.Domain.csproj index 01e0878..ec37b2f 100644 --- a/src/CrowdParlay.Users.Domain/CrowdParlay.Users.Domain.csproj +++ b/src/CrowdParlay.Users.Domain/CrowdParlay.Users.Domain.csproj @@ -1,13 +1,9 @@ - net7.0 + net9.0 enable enable - - - - \ No newline at end of file diff --git a/src/CrowdParlay.Users.Domain/Entities/ExternalLogin.cs b/src/CrowdParlay.Users.Domain/Entities/ExternalLogin.cs index ca0c05d..968f6b8 100644 --- a/src/CrowdParlay.Users.Domain/Entities/ExternalLogin.cs +++ b/src/CrowdParlay.Users.Domain/Entities/ExternalLogin.cs @@ -1,10 +1,8 @@ -using Dodo.Primitives; - namespace CrowdParlay.Users.Domain.Entities; -public class ExternalLogin : EntityBase +public class ExternalLogin : EntityBase { - public Uuid UserId { get; set; } + public Guid UserId { get; set; } public string ProviderId { get; set; } public string Identity { get; set; } } \ No newline at end of file diff --git a/src/CrowdParlay.Users.Domain/Entities/User.cs b/src/CrowdParlay.Users.Domain/Entities/User.cs index b8ca744..9536568 100644 --- a/src/CrowdParlay.Users.Domain/Entities/User.cs +++ b/src/CrowdParlay.Users.Domain/Entities/User.cs @@ -1,8 +1,6 @@ -using Dodo.Primitives; - namespace CrowdParlay.Users.Domain.Entities; -public class User : EntityBase +public class User : EntityBase { public required string Username { get; set; } public required string DisplayName { get; set; } diff --git a/src/CrowdParlay.Users.Domain/Page.cs b/src/CrowdParlay.Users.Domain/Page.cs new file mode 100644 index 0000000..9f55aeb --- /dev/null +++ b/src/CrowdParlay.Users.Domain/Page.cs @@ -0,0 +1,3 @@ +namespace CrowdParlay.Users.Domain; + +public record Page(int TotalCount, IEnumerable Items); \ No newline at end of file diff --git a/src/CrowdParlay.Users.Domain/SortingStrategy.cs b/src/CrowdParlay.Users.Domain/SortingStrategy.cs new file mode 100644 index 0000000..b89b3b0 --- /dev/null +++ b/src/CrowdParlay.Users.Domain/SortingStrategy.cs @@ -0,0 +1,7 @@ +namespace CrowdParlay.Users.Domain; + +public enum SortingStrategy +{ + NewestFirst, + OldestFirst, +} \ No newline at end of file diff --git a/src/CrowdParlay.Users.Infrastructure.Persistence/CrowdParlay.Users.Infrastructure.Persistence.csproj b/src/CrowdParlay.Users.Infrastructure.Persistence/CrowdParlay.Users.Infrastructure.Persistence.csproj index d122d08..d6e311b 100644 --- a/src/CrowdParlay.Users.Infrastructure.Persistence/CrowdParlay.Users.Infrastructure.Persistence.csproj +++ b/src/CrowdParlay.Users.Infrastructure.Persistence/CrowdParlay.Users.Infrastructure.Persistence.csproj @@ -1,25 +1,28 @@ - net7.0 + net9.0 enable enable - - - - - - - - - - - - - + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/CrowdParlay.Users.Infrastructure.Persistence/Extensions/ConfigureServices.cs b/src/CrowdParlay.Users.Infrastructure.Persistence/Extensions/ConfigureServices.cs index cd715c3..5c665bf 100644 --- a/src/CrowdParlay.Users.Infrastructure.Persistence/Extensions/ConfigureServices.cs +++ b/src/CrowdParlay.Users.Infrastructure.Persistence/Extensions/ConfigureServices.cs @@ -2,10 +2,10 @@ using CrowdParlay.Users.Domain.Abstractions; using CrowdParlay.Users.Infrastructure.Persistence.Abstractions; using CrowdParlay.Users.Infrastructure.Persistence.Services; -using CrowdParlay.Users.Infrastructure.Persistence.SqlTypeHandlers; using Dapper; using FluentMigrator.Runner; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -15,7 +15,6 @@ public static class ConfigureServices { public static IServiceCollection ConfigurePersistenceServices(this IServiceCollection services, IConfiguration configuration) { - SqlMapper.AddTypeHandler(new DodoUuidTypeHandler()); DefaultTypeMap.MatchNamesWithUnderscores = true; var connectionString = @@ -31,6 +30,7 @@ public static IServiceCollection ConfigurePersistenceServices(this IServiceColle .WithGlobalConnectionString(connectionString) .ScanIn(Assembly.GetExecutingAssembly()).For.All()) .AddDbContext(options => options + .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)) .UseNpgsql(connectionString) .UseOpenIddict()) .AddSingleton(new SqlConnectionFactory(connectionString)) diff --git a/src/CrowdParlay.Users.Infrastructure.Persistence/Migrations/EfCore/OpenIddictDbContextModelSnapshot.cs b/src/CrowdParlay.Users.Infrastructure.Persistence/Migrations/EfCore/OpenIddictDbContextModelSnapshot.cs index 2eb2cac..33a7293 100644 --- a/src/CrowdParlay.Users.Infrastructure.Persistence/Migrations/EfCore/OpenIddictDbContextModelSnapshot.cs +++ b/src/CrowdParlay.Users.Infrastructure.Persistence/Migrations/EfCore/OpenIddictDbContextModelSnapshot.cs @@ -1,5 +1,4 @@ // -using System; using CrowdParlay.Users.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -17,244 +16,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.12") + .HasAnnotation("ProductVersion", "9.0.6") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("text"); - - b.Property("ClientId") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("ClientSecret") - .HasColumnType("text"); - - b.Property("ConcurrencyToken") - .IsConcurrencyToken() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("ConsentType") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("DisplayName") - .HasColumnType("text"); - - b.Property("DisplayNames") - .HasColumnType("text"); - - b.Property("Permissions") - .HasColumnType("text"); - - b.Property("PostLogoutRedirectUris") - .HasColumnType("text"); - - b.Property("Properties") - .HasColumnType("text"); - - b.Property("RedirectUris") - .HasColumnType("text"); - - b.Property("Requirements") - .HasColumnType("text"); - - b.Property("Type") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("ClientId") - .IsUnique(); - - b.ToTable("OpenIddictApplications", (string)null); - }); - - modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("text"); - - b.Property("ApplicationId") - .HasColumnType("text"); - - b.Property("ConcurrencyToken") - .IsConcurrencyToken() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("CreationDate") - .HasColumnType("timestamp with time zone"); - - b.Property("Properties") - .HasColumnType("text"); - - b.Property("Scopes") - .HasColumnType("text"); - - b.Property("Status") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Subject") - .HasMaxLength(400) - .HasColumnType("character varying(400)"); - - b.Property("Type") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationId", "Status", "Subject", "Type"); - - b.ToTable("OpenIddictAuthorizations", (string)null); - }); - - modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("text"); - - b.Property("ConcurrencyToken") - .IsConcurrencyToken() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Description") - .HasColumnType("text"); - - b.Property("Descriptions") - .HasColumnType("text"); - - b.Property("DisplayName") - .HasColumnType("text"); - - b.Property("DisplayNames") - .HasColumnType("text"); - - b.Property("Name") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Properties") - .HasColumnType("text"); - - b.Property("Resources") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("OpenIddictScopes", (string)null); - }); - - modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("text"); - - b.Property("ApplicationId") - .HasColumnType("text"); - - b.Property("AuthorizationId") - .HasColumnType("text"); - - b.Property("ConcurrencyToken") - .IsConcurrencyToken() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("CreationDate") - .HasColumnType("timestamp with time zone"); - - b.Property("ExpirationDate") - .HasColumnType("timestamp with time zone"); - - b.Property("Payload") - .HasColumnType("text"); - - b.Property("Properties") - .HasColumnType("text"); - - b.Property("RedemptionDate") - .HasColumnType("timestamp with time zone"); - - b.Property("ReferenceId") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Status") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Subject") - .HasMaxLength(400) - .HasColumnType("character varying(400)"); - - b.Property("Type") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("AuthorizationId"); - - b.HasIndex("ReferenceId") - .IsUnique(); - - b.HasIndex("ApplicationId", "Status", "Subject", "Type"); - - b.ToTable("OpenIddictTokens", (string)null); - }); - - modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => - { - b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") - .WithMany("Authorizations") - .HasForeignKey("ApplicationId"); - - b.Navigation("Application"); - }); - - modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => - { - b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") - .WithMany("Tokens") - .HasForeignKey("ApplicationId"); - - b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") - .WithMany("Tokens") - .HasForeignKey("AuthorizationId"); - - b.Navigation("Application"); - - b.Navigation("Authorization"); - }); - - modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => - { - b.Navigation("Authorizations"); - - b.Navigation("Tokens"); - }); - - modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => - { - b.Navigation("Tokens"); - }); #pragma warning restore 612, 618 } } diff --git a/src/CrowdParlay.Users.Infrastructure.Persistence/OpenIddictDbContextFactory.cs b/src/CrowdParlay.Users.Infrastructure.Persistence/OpenIddictDbContextFactory.cs new file mode 100644 index 0000000..aabc023 --- /dev/null +++ b/src/CrowdParlay.Users.Infrastructure.Persistence/OpenIddictDbContextFactory.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace CrowdParlay.Users.Infrastructure.Persistence; + +public class OpenIddictDbContextFactory : IDesignTimeDbContextFactory +{ + public OpenIddictDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=dummy;Username=dummy;Password=dummy"); + return new OpenIddictDbContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/src/CrowdParlay.Users.Infrastructure.Persistence/Order.cs b/src/CrowdParlay.Users.Infrastructure.Persistence/Order.cs new file mode 100644 index 0000000..30e3fe2 --- /dev/null +++ b/src/CrowdParlay.Users.Infrastructure.Persistence/Order.cs @@ -0,0 +1,7 @@ +namespace CrowdParlay.Users.Infrastructure.Persistence; + +public enum Order +{ + Ascending, + Descending +} \ No newline at end of file diff --git a/src/CrowdParlay.Users.Infrastructure.Persistence/Services/ExternalLoginsRepository.cs b/src/CrowdParlay.Users.Infrastructure.Persistence/Services/ExternalLoginsRepository.cs index 6663525..e6cc049 100644 --- a/src/CrowdParlay.Users.Infrastructure.Persistence/Services/ExternalLoginsRepository.cs +++ b/src/CrowdParlay.Users.Infrastructure.Persistence/Services/ExternalLoginsRepository.cs @@ -3,7 +3,6 @@ using CrowdParlay.Users.Domain.Entities; using CrowdParlay.Users.Infrastructure.Persistence.Abstractions; using Dapper; -using Dodo.Primitives; namespace CrowdParlay.Users.Infrastructure.Persistence.Services; @@ -14,7 +13,7 @@ public class ExternalLoginsRepository : IExternalLoginsRepository public ExternalLoginsRepository(IDbConnectionFactory connectionFactory) => _connectionFactory = connectionFactory; - public async Task> GetByUserIdAsync(Uuid userId, CancellationToken cancellationToken) + public async Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken) { await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); return await connection.QueryAsync( @@ -43,7 +42,7 @@ await connection.ExecuteAsync( entity); } - public async Task DeleteAsync(Uuid id, CancellationToken cancellationToken) + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) { await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); diff --git a/src/CrowdParlay.Users.Infrastructure.Persistence/Services/UsersRepository.cs b/src/CrowdParlay.Users.Infrastructure.Persistence/Services/UsersRepository.cs index ebf81de..5b21c03 100644 --- a/src/CrowdParlay.Users.Infrastructure.Persistence/Services/UsersRepository.cs +++ b/src/CrowdParlay.Users.Infrastructure.Persistence/Services/UsersRepository.cs @@ -1,10 +1,10 @@ using System.Runtime.CompilerServices; using CrowdParlay.Users.Application.Exceptions; +using CrowdParlay.Users.Domain; using CrowdParlay.Users.Domain.Abstractions; using CrowdParlay.Users.Domain.Entities; using CrowdParlay.Users.Infrastructure.Persistence.Abstractions; using Dapper; -using Dodo.Primitives; namespace CrowdParlay.Users.Infrastructure.Persistence.Services; @@ -15,6 +15,30 @@ internal class UsersRepository : IUsersRepository public UsersRepository(IDbConnectionFactory connectionFactory) => _connectionFactory = connectionFactory; + public async Task> SearchAsync(SortingStrategy order, int offset, int count, CancellationToken cancellationToken) + { + var orderByCreatedAtDirection = order switch + { + SortingStrategy.NewestFirst => "DESC", + SortingStrategy.OldestFirst => "ASC", + _ => throw new ArgumentOutOfRangeException(nameof(order), order, "The specified order is not supported.") + }; + + await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); + var result = await connection.QueryMultipleAsync( + $""" + SELECT COUNT(*) FROM {UsersSchema.Table}; + SELECT * FROM {UsersSchema.Table} + ORDER BY {UsersSchema.CreatedAt} {orderByCreatedAtDirection} + LIMIT @{nameof(count)} OFFSET @{nameof(offset)}; + """, + new { offset, count }); + + var totalCount = await result.ReadSingleAsync(); + var users = await result.ReadAsync(); + return new Page(totalCount, users); + } + public async IAsyncEnumerable GetByIdsAsync(Guid[] ids, [EnumeratorCancellation] CancellationToken cancellationToken) { await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); @@ -27,7 +51,7 @@ public async IAsyncEnumerable GetByIdsAsync(Guid[] ids, [EnumeratorCancell yield return parser(reader); } - public async Task GetByIdAsync(Uuid id, CancellationToken cancellationToken) + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) { await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); return await connection.QuerySingleOrDefaultAsync( @@ -64,21 +88,13 @@ public async IAsyncEnumerable GetByIdsAsync(Guid[] ids, [EnumeratorCancell await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); return await connection.QuerySingleOrDefaultAsync( $""" - SELECT * FROM {UsersSchema.Table} - WHERE {UsersSchema.UsernameNormalized} = normalize_username(@{nameof(usernameOrEmail)}) - OR {UsersSchema.EmailNormalized} = normalize_email(@{nameof(usernameOrEmail)}) - """, + SELECT * FROM {UsersSchema.Table} + WHERE {UsersSchema.UsernameNormalized} = normalize_username(@{nameof(usernameOrEmail)}) + OR {UsersSchema.EmailNormalized} = normalize_email(@{nameof(usernameOrEmail)}) + """, new { usernameOrEmail }); } - public async Task> GetManyAsync(int count, int page, CancellationToken cancellationToken) - { - await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); - return await connection.QueryAsync( - $"SELECT * FROM {UsersSchema.Table} LIMIT @{nameof(count)} OFFSET @{nameof(count)} * @{nameof(page)}", - new { count, page }); - } - public async Task AddAsync(User entity, CancellationToken cancellationToken) { await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); @@ -124,7 +140,7 @@ await connection.ExecuteAsync( entity); } - public async Task DeleteAsync(Uuid id, CancellationToken cancellationToken) + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) { await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); diff --git a/src/CrowdParlay.Users.Infrastructure.Persistence/SqlTypeHandlers/DodoUuidTypeHandler.cs b/src/CrowdParlay.Users.Infrastructure.Persistence/SqlTypeHandlers/DodoUuidTypeHandler.cs deleted file mode 100644 index f74edf5..0000000 --- a/src/CrowdParlay.Users.Infrastructure.Persistence/SqlTypeHandlers/DodoUuidTypeHandler.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Data; -using Dapper; -using Dodo.Primitives; - -namespace CrowdParlay.Users.Infrastructure.Persistence.SqlTypeHandlers; - -public class DodoUuidTypeHandler : SqlMapper.TypeHandler -{ - public override void SetValue(IDbDataParameter parameter, Uuid value) => - parameter.Value = value.ToGuidStringLayout(); - - public override Uuid Parse(object value) => Uuid.Parse(value.ToString()!); -} \ No newline at end of file diff --git a/src/CrowdParlay.Users.Infrastructure/CrowdParlay.Users.Infrastructure.csproj b/src/CrowdParlay.Users.Infrastructure/CrowdParlay.Users.Infrastructure.csproj index e32e271..945f733 100644 --- a/src/CrowdParlay.Users.Infrastructure/CrowdParlay.Users.Infrastructure.csproj +++ b/src/CrowdParlay.Users.Infrastructure/CrowdParlay.Users.Infrastructure.csproj @@ -1,15 +1,15 @@ - net7.0 + net9.0 enable enable - + - + diff --git a/tests/CrowdParlay.Users.IntegrationTests/CrowdParlay.Users.IntegrationTests.csproj b/tests/CrowdParlay.Users.IntegrationTests/CrowdParlay.Users.IntegrationTests.csproj index 2aae6a8..f7828f7 100644 --- a/tests/CrowdParlay.Users.IntegrationTests/CrowdParlay.Users.IntegrationTests.csproj +++ b/tests/CrowdParlay.Users.IntegrationTests/CrowdParlay.Users.IntegrationTests.csproj @@ -1,7 +1,7 @@ - net7.0 + net9.0 enable enable @@ -15,13 +15,13 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/CrowdParlay.Users.IntegrationTests/Tests/HealthChecksTests.cs b/tests/CrowdParlay.Users.IntegrationTests/Tests/HealthChecksTests.cs index d669484..ffea82e 100644 --- a/tests/CrowdParlay.Users.IntegrationTests/Tests/HealthChecksTests.cs +++ b/tests/CrowdParlay.Users.IntegrationTests/Tests/HealthChecksTests.cs @@ -14,7 +14,7 @@ public class HealthChecksTests : IAssemblyFixture public async Task GetHealth_ReturnsHealthy() { var response = await _client.GetAsync("/healthz"); - response.Should().HaveStatusCode(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); var content = await response.Content.ReadAsStringAsync(); content.Should().BeEquivalentTo("healthy"); diff --git a/tests/CrowdParlay.Users.IntegrationTests/Tests/TraceIdMiddlewareTests.cs b/tests/CrowdParlay.Users.IntegrationTests/Tests/TraceIdMiddlewareTests.cs index 9585546..a6e94da 100644 --- a/tests/CrowdParlay.Users.IntegrationTests/Tests/TraceIdMiddlewareTests.cs +++ b/tests/CrowdParlay.Users.IntegrationTests/Tests/TraceIdMiddlewareTests.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Net.Http.Json; using CrowdParlay.Users.Application.Features.Users.Commands; using CrowdParlay.Users.IntegrationTests.Fixtures; using FluentAssertions; @@ -26,8 +28,8 @@ public async Task RegisterUserOnFailure_ReturnsTraceId() var command = new Register.Command(string.Empty, string.Empty, string.Empty,string.Empty, null, null); var response = await _client.PostAsJsonAsync("/api/v1/users/register", command); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); response.Headers.Should().Contain(header => header.Key == TraceIdHeaderName); - response.Should().HaveClientError(); } [Fact(DisplayName = "Register users returns unique trace IDs", Timeout = 5000)] @@ -40,7 +42,7 @@ public async Task RegisterUser_ReturnsUniqueTraceIds() successMessage.Headers.Should().Contain(header => header.Key == TraceIdHeaderName); failureMessage.Headers.Should().Contain(header => header.Key == TraceIdHeaderName); - failureMessage.Should().HaveClientError(); + failureMessage.StatusCode.Should().Be(HttpStatusCode.BadRequest); var successMessageTraceId = successMessage.Headers.GetValues(TraceIdHeaderName).ToList(); var failureMessageTraceId = failureMessage.Headers.GetValues(TraceIdHeaderName).ToList(); diff --git a/tests/CrowdParlay.Users.IntegrationTests/Tests/UsersControllerTests.cs b/tests/CrowdParlay.Users.IntegrationTests/Tests/UsersControllerTests.cs index 8623a83..1e0d9fe 100644 --- a/tests/CrowdParlay.Users.IntegrationTests/Tests/UsersControllerTests.cs +++ b/tests/CrowdParlay.Users.IntegrationTests/Tests/UsersControllerTests.cs @@ -6,22 +6,54 @@ using CrowdParlay.Users.Api; using CrowdParlay.Users.Application.Features.Users.Commands; using CrowdParlay.Users.Application.Features.Users.Queries; +using CrowdParlay.Users.Domain; using CrowdParlay.Users.IntegrationTests.Extensions; using CrowdParlay.Users.IntegrationTests.Fixtures; using FluentAssertions; using MassTransit.Testing; +using Mediator; +using Microsoft.Extensions.DependencyInjection; namespace CrowdParlay.Users.IntegrationTests.Tests; +[Collection(nameof(UsersControllerTests))] +[CollectionDefinition(nameof(UsersControllerTests), DisableParallelization = true)] public class UsersControllerTests : IAssemblyFixture { private readonly HttpClient _client; private readonly ITestHarness _harness; + private readonly IServiceScopeFactory _serviceScopeFactory; public UsersControllerTests(WebApplicationFixture fixture) { _client = fixture.WebApplicationFactory.CreateDefaultClient(); _harness = fixture.Services.GetTestHarness(); + _serviceScopeFactory = fixture.Services.GetRequiredService(); + } + + [Fact(DisplayName = "Search users")] + public async Task SearchUsers_Positive() + { + await using var scope = _serviceScopeFactory.CreateAsyncScope(); + var sender = scope.ServiceProvider.GetRequiredService(); + + var registerUsersTasks = Enumerable.Range(0, 200).Select(i => sender.Send(new Register.Command( + username: $"test_user_{i}_{Guid.NewGuid():N}"[..25], + email: $"test_user_{i}@example.com", + displayName: $"test_user_{i}", + password: Guid.NewGuid().ToString("N")[..25], + avatarUrl: null, + externalLoginTicket: null))); + + foreach (var task in registerUsersTasks) + await task; + + var response = await _client.GetFromJsonAsync>( + "/api/v1/users?order=newestFirst&offset=0&count=100", + GlobalSerializerOptions.SnakeCase); + + response!.TotalCount.Should().BeGreaterOrEqualTo(200); + response.Items.Count().Should().Be(100); } [Theory(DisplayName = "Register users")] @@ -51,7 +83,7 @@ public async Task RegisterUsernames_Positive(string username, HttpStatusCode exp externalLoginTicket: null); var response = await _client.PostAsJsonAsync("/api/v1/users/register", request, GlobalSerializerOptions.SnakeCase); - response.Should().HaveStatusCode(expectedStatusCode); + response.StatusCode.Should().Be(expectedStatusCode); } [Fact(DisplayName = "Register user returns new user and publishes event")] @@ -85,7 +117,7 @@ public async Task Register_Negative() var registerRequestDuplicate = new Register.Command("us55e3rn44me3333e", "meily@tup.ye", "display name 2", "password2!", null, null); var duplicateMessage = await _client.PostAsJsonAsync("/api/v1/users/register", registerRequestDuplicate, GlobalSerializerOptions.SnakeCase); - duplicateMessage.Should().HaveStatusCode(HttpStatusCode.BadRequest); + duplicateMessage.StatusCode.Should().Be(HttpStatusCode.BadRequest); } [Fact(DisplayName = "Register user with invalid username returns validation failures", Timeout = 5000)] @@ -93,7 +125,7 @@ public async Task RegisterValidation_Negative() { var registerRequest = new Register.Command(string.Empty, string.Empty, string.Empty, "Password", null, null); var registerMessage = await _client.PostAsJsonAsync("/api/v1/users/register", registerRequest, GlobalSerializerOptions.SnakeCase); - registerMessage.Should().HaveStatusCode(HttpStatusCode.BadRequest); + registerMessage.StatusCode.Should().Be(HttpStatusCode.BadRequest); var validationProblem = await registerMessage.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); validationProblem!.ValidationErrors.Should().ContainKey("username").WhoseValue.Should().HaveCount(3); @@ -108,7 +140,7 @@ public async Task GetById_Positive() var registerResponse = await registerMessage.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); var getByIdMessage = await _client.GetAsync($"/api/v1/users/{registerResponse!.Id}"); - getByIdMessage.Should().HaveStatusCode(HttpStatusCode.OK); + getByIdMessage.StatusCode.Should().Be(HttpStatusCode.OK); var getByIdResponse = await getByIdMessage.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); getByIdResponse.Should().Be(new GetById.Response( @@ -126,7 +158,7 @@ public async Task GetByUsername_Positive() var registerResponse = await registerMessage.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); var getByUsernameMessage = await _client.GetAsync($"/api/v1/users/resolve?username={registerResponse!.Username}"); - getByUsernameMessage.Should().HaveStatusCode(HttpStatusCode.OK); + getByUsernameMessage.StatusCode.Should().Be(HttpStatusCode.OK); var getByUsernameResponse = await getByUsernameMessage.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); getByUsernameResponse.Should().Be(new GetByUsername.Response( @@ -161,7 +193,7 @@ public async Task Update_Positive() }; var updateMessage = await _client.SendAsync(requestMessage); - updateMessage.Should().HaveStatusCode(HttpStatusCode.OK); + updateMessage.StatusCode.Should().Be(HttpStatusCode.OK); var updateResponse = await updateMessage.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); updateResponse.Should().Be(new Update.Response( @@ -179,7 +211,7 @@ public async Task Update_Positive() updateResponse.AvatarUrl)); var getByIdMessage = await _client.GetAsync($"/api/v1/users/{registerResponse.Id}"); - getByIdMessage.Should().HaveStatusCode(HttpStatusCode.OK); + getByIdMessage.StatusCode.Should().Be(HttpStatusCode.OK); var getByIdResponse = await getByIdMessage.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); getByIdResponse.Should().Be(new GetById.Response( @@ -214,7 +246,7 @@ public async Task UpdatePassword_Positive() }; var updateMessage = await _client.SendAsync(requestMessage); - updateMessage.Should().HaveStatusCode(HttpStatusCode.OK); + updateMessage.StatusCode.Should().Be(HttpStatusCode.OK); var updateResponse = await updateMessage.Content.ReadFromJsonAsync(GlobalSerializerOptions.SnakeCase); updateResponse.Should().Be(new Update.Response(